diff --git a/.prettierignore b/.prettierignore index 101dc6ab..fe5a688d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,7 @@ package-lock.json *.AppImage *.deb *.dmg + +# Android build outputs +android/.gradle/ +android/**/build/ diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..7501d2e4 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +.kotlin/ +.idea/ +build/ +local.properties +*.iml diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..0f6cf218 --- /dev/null +++ b/android/README.md @@ -0,0 +1,83 @@ +# Parallel Code for Android + +Native companion app for the desktop's **Connect Phone** (Remote Access) feature. It talks to the same HTTP/WebSocket API as the phone web UI in `src/remote/`. + +## What it does + +- **Connect:** scan the QR code in Connect Phone, or paste the link under it. This gives a view-only token. +- **Pair:** enter the six-digit code from Connect Phone to get a paired token, which may type into terminals. "Keep this phone authorized" asks the desktop to remember the phone across restarts. +- **Several computers:** link more than one desktop (for example the installed app and a dev build, or two machines) and switch between them in Settings → Computers; each keeps its own pairing. +- **Agents:** live list with each agent's status and last line, under the desktop's Claude, Codex, and Antigravity 5-hour and weekly usage meters (hidden on desktops without `/api/mobile/usage`). +- **Minimized tasks:** tasks minimized on the desktop are pinned below the live list; a setting hides them. +- **Looks:** the same 15 themes as the desktop, in Settings → Appearance. Follow system / always dark / always light picks the tone, and a separate dark and light look is remembered, so switching your phone's theme switches the look with it. Each look is drawn with a live swatch, and every color and corner radius comes from the desktop's own stylesheet. See [Looks](#looks). +- **Settings:** theme and looks, keep the screen on, widget background transparency and card color, connection status, 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"). +- **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). 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. +- **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 reply box (edit them in Settings) and a mic button that dictates with Android's speech recognizer. +- **Widget:** a home-screen widget with the agents that need you and the usage meters, updated while the app is connected. Settings → Widget sets its background transparency (opaque, 75%, 50% or 25%; the border fades with the card, so your wallpaper shows through) and its card color (Obsidian, Slate or Light, each with text colors that stay readable). +- **Notes:** read a task's notes panel; edit and save it once paired. +- **New task:** pick a project and describe the work; needs pairing. +- **Notifications:** optional, in Settings. A foreground service keeps the connection open in the background and notifies when an agent needs input, hits an error, or finishes (each can be turned off); tapping one opens that agent. +- **Close task:** from an agent's screen; needs pairing. Like the desktop, it warns before losing uncommitted or unmerged work. + +- **Built-in chat:** read the conversation, send messages, stop the agent, and answer its approvals and questions once paired. Choosing the model and attaching images stay on the computer. + +## Looks + +The phone uses the desktop's look presets, not its own. `LookPalettes.kt` is generated from the files the desktop already keeps its looks in: + +| Desktop source | What it contributes | +| ------------------ | ------------------------------------------------------------ | +| `src/lib/look.ts` | Preset ids, labels, descriptions, order, and light/dark tone | +| `src/styles.css` | The colors and the corner radius scale | +| `src/lib/theme.ts` | The terminal ANSI palettes and which look pairs with which | + +```sh +npm run generate:android-looks # rewrite LookPalettes.kt after a desktop theme change +npm run check:android-looks # fail if it is out of date (also run by the Kotlin tests) +``` + +Three things are worth knowing about the mapping: + +- **The cascade is resolved, not copied.** Each desktop theme sets only the variables it changes and inherits the rest from `:root`, so the generator resolves the full palette per preset. The phone has no fallback values of its own. +- **Gradients are flattened.** Several desktop backgrounds are `radial-gradient`s. The phone draws flat surfaces, so a gradient becomes its middle stop, which keeps the look recognizable. Everything else is the exact value. +- **Terminals follow the look.** A terminal is drawn over the look's `--task-panel-bg` with the ANSI set the desktop pairs with that look, so Midnight gets a pure-black panel and Noir gets Noir's ANSI colors. Dark looks with no set of their own on the desktop fall back to the muted Noir set, because the desktop's fallback there is xterm's own defaults. + +Obsidian in both tones is the default, and its values are pinned by `LookPalettesTest`, so adding a theme cannot quietly change what the app looks like out of the box. + +## Build + +Needs JDK 17+ and the Android SDK (compile SDK 37). Set `ANDROID_HOME` or add `sdk.dir` to `android/local.properties`. + +```sh +cd android +./gradlew testDebugUnitTest # unit tests +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew installDebug # install on a connected device +``` + +QR scanning uses the Google Play services code scanner, so the app needs no camera permission. On phones without Play services, paste the link instead. + +## How it maps to the server + +See `electron/remote/server.ts` and `electron/remote/protocol.ts`. + +| Step | Request | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Pair | `POST /api/pair/verify` with `Authorization: Bearer ` and `{ pin, remember }`; returns `{ token }` | +| 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 | +| Projects | `GET /api/mobile/projects` (paired) | +| New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt }` (paired); returns `{ taskId }` | +| Usage | `GET /api/mobile/usage`; the desktop status bar's snapshot, readable view-only | +| Notes | `GET` / `PUT /api/mobile/notes/` with `{ notes }`; reading works view-only, saving needs pairing | +| Close task | `POST /api/mobile/tasks//close` with `{ force }` (paired); `409` with `{ warnings }` when work would be lost | +| Changes | `GET /api/mobile/tasks//diff` → `{ diff, truncated, unsupported }`; readable view-only | +| Reply | `input` with `submit: true` and a `requestId`; confirmed by `input-result` | +| Close `4001` | Paired token rejected: drop it and reconnect view-only. QR token rejected: scan again | +| Close `4003` | Typing rights lost: drop the paired token | +| HTTP 401 | On a paired-token request: drop the paired token and reconnect view-only | + +Remote Access serves plain HTTP on the LAN or Tailscale address, so the app allows cleartext traffic. Credentials live in app-private storage and are excluded from backups and device transfer. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..1d9cf6da --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.parallelcode.phone" + compileSdk = 37 + + defaultConfig { + applicationId = "com.parallelcode.phone" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + // R8 drops unused code and resources; the libraries ship their own keep rules. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2026.09.00")) + implementation("androidx.compose.material3:material3") + // Look preset rows show a check mark on the selected theme. + implementation("androidx.compose.material:material-icons-core") + // Stop, history and mic buttons: these icons only ship in the extended set. + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + // Installs the baseline profiles Compose ships, so a sideloaded APK starts and scrolls + // compiled rather than interpreted. + implementation("androidx.profileinstaller:profileinstaller:1.4.1") + // Scanner UI comes from Google Play services, so the app needs no camera permission. + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") + + testImplementation("junit:junit:4.13.2") + // android.jar only has stubs for org.json; unit tests need the real implementation. + testImplementation("org.json:json:20260814") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..84e314b9 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# App-specific R8 keep rules. OkHttp, Compose and ML Kit bring their own consumer rules, and the +# app reads JSON through org.json (part of Android), so nothing needs keeping here yet. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..de3172cc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt b/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt new file mode 100644 index 00000000..cfaafd74 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt @@ -0,0 +1,37 @@ +package com.parallelcode.phone + +/** An agent change worth a notification. */ +enum class AgentEvent { NEEDS_INPUT, ERROR, FINISHED } + +data class AgentNotice(val agent: RemoteAgent, val event: AgentEvent) + +private val BUSY = setOf("active", "shell_busy") +private val SETTLED = setOf("ready", "review", "idle") + +/** + * Turns successive agent lists into notices. The first list is the baseline: agents already + * waiting when watching starts are not announced. + */ +class AgentNotifier { + private var previous: Map? = null + + fun update(agents: List): List { + val before = previous + previous = agents.associateBy { it.agentId } + if (before == null) return emptyList() + return agents.mapNotNull { agent -> + val old = before[agent.agentId] ?: return@mapNotNull null + eventFor(old, agent)?.let { AgentNotice(agent, it) } + } + } +} + +internal fun eventFor(old: RemoteAgent, new: RemoteAgent): AgentEvent? = when { + new.collapsed -> null + new.attention == old.attention && new.running == old.running -> null + new.attention == "needs_input" -> AgentEvent.NEEDS_INPUT + new.attention == "error" -> AgentEvent.ERROR + old.running && !new.running -> AgentEvent.FINISHED + old.attention in BUSY && new.attention in SETTLED -> AgentEvent.FINISHED + else -> null +} diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt b/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt new file mode 100644 index 00000000..89f0758a --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt @@ -0,0 +1,156 @@ +package com.parallelcode.phone + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Keeps the desktop connection open while the app is in the background and notifies when an agent + * needs input, hits an error or finishes. Runs only while agent notifications are on in Settings. + */ +class AgentWatchService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val app get() = application as PhoneApplication + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannels(this) + ServiceCompat.startForeground( + this, + WATCHING_ID, + watchingNotification(0), + if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0, + ) + app.client.start(HOLDER) + // The widget's usage lines; the app refreshes them itself while on screen. + scope.launch { + while (true) { + if (!app.inForeground) runCatching { app.client.fetchUsage() } + delay(USAGE_REFRESH_MS) + } + } + val notifier = AgentNotifier() + scope.launch { + app.client.agents.collect { agents -> + val notices = notifier.update(agents) + if (!canNotify()) return@collect + val manager = NotificationManagerCompat.from(this@AgentWatchService) + if (!app.inForeground) { + notices.filter { app.settings.notifiesFor(it.event) }.forEach { notice -> + manager.notify(notice.agent.agentId.hashCode(), agentNotification(notice)) + } + } + manager.notify(WATCHING_ID, watchingNotification(agents.count { it.attention == "needs_input" })) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + override fun onDestroy() { + scope.cancel() + app.client.stop(HOLDER) + super.onDestroy() + } + + private fun canNotify() = Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun openApp(agentId: String?): PendingIntent { + val intent = Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + agentId?.let { intent.putExtra(MainActivity.EXTRA_AGENT_ID, it) } + return PendingIntent.getActivity( + this, + agentId?.hashCode() ?: 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private fun watchingNotification(needInput: Int) = + NotificationCompat.Builder(this, CHANNEL_WATCHING) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Watching your agents") + .setContentText( + when (needInput) { + 0 -> "You'll be notified when one needs you." + 1 -> "1 agent needs input." + else -> "$needInput agents need input." + }, + ) + .setOngoing(true) + .setSilent(true) + .setContentIntent(openApp(null)) + .build() + + private fun agentNotification(notice: AgentNotice) = + NotificationCompat.Builder(this, CHANNEL_AGENTS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(notice.agent.taskName) + .setContentText( + when (notice.event) { + AgentEvent.NEEDS_INPUT -> "Needs your input" + AgentEvent.ERROR -> "Hit an error" + AgentEvent.FINISHED -> "Finished" + }, + ) + .setSubText(notice.agent.agentName) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(openApp(notice.agent.agentId)) + .build() + + companion object { + private const val HOLDER = "notifications" + private const val USAGE_REFRESH_MS = 5 * 60_000L + private const val WATCHING_ID = 1 + private const val CHANNEL_AGENTS = "agents" + private const val CHANNEL_WATCHING = "watching" + + /** Start or stop the service to match the setting and whether a computer is linked. */ + fun sync(context: Context) { + val app = context.applicationContext as PhoneApplication + val intent = Intent(context, AgentWatchService::class.java) + if (app.settings.notificationsEnabled && app.client.state.value.link != null) { + ContextCompat.startForegroundService(context, intent) + } else { + context.stopService(intent) + } + } + + private fun createChannels(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_AGENTS, "Agent updates", NotificationManager.IMPORTANCE_HIGH) + .apply { description = "An agent needs input, hit an error or finished." }, + ) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_WATCHING, "Watching agents", NotificationManager.IMPORTANCE_MIN) + .apply { description = "Shown while the app keeps the connection open in the background." }, + ) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt new file mode 100644 index 00000000..133998d4 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt @@ -0,0 +1,189 @@ +package com.parallelcode.phone + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import androidx.core.content.edit +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** How opaque the widget's card is, in the stops the settings slider offers, opaque first. */ +val WIDGET_TRANSPARENCY_STEPS = listOf(100, 75, 50, 25) + +/** Snap [percent] to the nearest stop, so the slider and a hand-edited value always land on a card. */ +fun widgetTransparencyStep(percent: Int): Int = + WIDGET_TRANSPARENCY_STEPS.minByOrNull { abs(it - percent) } ?: WIDGET_TRANSPARENCY_STEPS.first() + +/** + * A card color, with text colors that stay readable on it. The card's fill and border are the + * same color; the Light card carries a light grey border so its edge reads against white. + */ +data class WidgetPalette( + val key: String, + val label: String, + /** The opaque card fill, so Settings can preview the card without loading a drawable. */ + val fill: Int, + val title: Int, + val headline: Int, + val usage: Int, + val updated: Int, + /** The card shape at each transparency stop; RemoteViews sets a background by resource only. */ + val backgrounds: Map, +) { + /** The card shape at [percent] opacity, snapped to a stop. */ + fun background(percent: Int): Int = backgrounds.getValue(widgetTransparencyStep(percent)) +} + +/** + * The widget cards draw in RemoteViews, which cannot read the app's Compose theme, + * so their colors are listed here rather than taken from the active look. The + * Obsidian card and the Light card do mirror the matching look presets + * ([LookPresets]); `LookPalettesTest` keeps them equal, so recoloring a preset + * cannot quietly leave the widget behind. + */ +val WIDGET_PALETTES = listOf( + WidgetPalette( + key = "obsidian", + label = "Obsidian", + fill = 0xFF1E1E1E.toInt(), // --island-bg + title = 0xFFC4A77D.toInt(), // --accent + headline = 0xFFEDEDED.toInt(), // --fg + usage = 0xFFB5B5B5.toInt(), // --fg-muted + updated = 0xFF919191.toInt(), // --fg-subtle + backgrounds = mapOf( + 100 to R.drawable.widget_card_obsidian_100, + 75 to R.drawable.widget_card_obsidian_75, + 50 to R.drawable.widget_card_obsidian_50, + 25 to R.drawable.widget_card_obsidian_25, + ), + ), + WidgetPalette( + key = "slate", + label = "Slate", + fill = 0xFF3A3F44.toInt(), + title = 0xFFD8C39B.toInt(), + headline = 0xFFF2F4F5.toInt(), + usage = 0xFFC4CACE.toInt(), + updated = 0xFFA8AFB4.toInt(), + backgrounds = mapOf( + 100 to R.drawable.widget_card_slate_100, + 75 to R.drawable.widget_card_slate_75, + 50 to R.drawable.widget_card_slate_50, + 25 to R.drawable.widget_card_slate_25, + ), + ), + WidgetPalette( + key = "light", + label = "Light", + fill = 0xFFFFFFFF.toInt(), // --island-bg + title = 0xFF8A6433.toInt(), // --accent + headline = 0xFF1F1F1F.toInt(), // --fg + usage = 0xFF555555.toInt(), // --fg-muted + updated = 0xFF6E6E6E.toInt(), // --fg-subtle + backgrounds = mapOf( + 100 to R.drawable.widget_card_light_100, + 75 to R.drawable.widget_card_light_75, + 50 to R.drawable.widget_card_light_50, + 25 to R.drawable.widget_card_light_25, + ), + ), +) + +/** The palette for a stored [key], falling back to Obsidian for an unknown one. */ +fun widgetPalette(key: String?): WidgetPalette = + WIDGET_PALETTES.firstOrNull { it.key == key } ?: WIDGET_PALETTES.first() + +/** The card shape for [key] at [percent] opacity. */ +fun widgetBackground(key: String?, percent: Int): Int = widgetPalette(key).background(percent) + +/** What the widget shows, worked out from the live agent list and usage snapshot. */ +data class WidgetSummary(val headline: String, val usage: String) + +fun widgetSummary(agents: List, usage: List, connected: Boolean): WidgetSummary { + val live = agents.filter { !it.collapsed } + val needInput = live.count { it.attention == "needs_input" || it.attention == "error" } + val working = live.count { it.running && (it.attention == "active" || it.attention == "shell_busy") } + val headline = when { + !connected -> "Not connected" + needInput > 0 && working > 0 -> "$needInput need you · $working working" + needInput > 0 -> "$needInput need${if (needInput == 1) "s" else ""} you" + working > 0 -> "$working working" + live.isEmpty() -> "No agents running" + else -> "All quiet" + } + val lines = usage.filter { it.hasSnapshot }.map { provider -> + val windows = listOfNotNull( + provider.fiveHour?.let { "5h ${it.remainingPercent}%" }, + provider.sevenDay?.let { "7d ${it.remainingPercent}%" }, + ).joinToString(" ") + "${provider.label.padEnd(11)} $windows" + } + return WidgetSummary(headline, if (lines.isEmpty()) "" else "Left:\n" + lines.joinToString("\n")) +} + +/** + * Home-screen widget: agents that need you and the usage meters. The app pushes updates while its + * connection is open (on screen, or in the background with notifications on); in between, the + * widget shows the last update and its time. + */ +class AgentWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) = render(context, manager, ids) + + companion object { + private const val PREFS = "widget" + private const val KEY_HEADLINE = "headline" + private const val KEY_USAGE = "usage" + private const val KEY_UPDATED = "updated" + + /** Store [summary] and redraw every placed widget. */ + fun publish(context: Context, summary: WidgetSummary) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (prefs.getString(KEY_HEADLINE, null) == summary.headline && prefs.getString(KEY_USAGE, null) == summary.usage) return + prefs.edit { + putString(KEY_HEADLINE, summary.headline) + putString(KEY_USAGE, summary.usage) + putString(KEY_UPDATED, LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) + } + refresh(context) + } + + /** Redraw every placed widget, so a settings change shows without waiting for new data. */ + fun refresh(context: Context) { + val manager = AppWidgetManager.getInstance(context) + render(context, manager, manager.getAppWidgetIds(ComponentName(context, AgentWidget::class.java))) + } + + private fun render(context: Context, manager: AppWidgetManager, ids: IntArray) { + if (ids.isEmpty()) return + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val settings = context.getSharedPreferences(SettingsStore.PREFS_NAME, Context.MODE_PRIVATE) + val open = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val palette = widgetPalette(settings.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + val transparency = settings.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 100) + val views = RemoteViews(context.packageName, R.layout.widget_agents).apply { + setTextViewText(R.id.widget_headline, prefs.getString(KEY_HEADLINE, null) ?: "Open the app to connect") + setTextViewText(R.id.widget_usage, prefs.getString(KEY_USAGE, null).orEmpty()) + setTextViewText(R.id.widget_updated, prefs.getString(KEY_UPDATED, null).orEmpty()) + // The card's color picks the text colors too, so a light card stays readable. + setTextColor(R.id.widget_title, palette.title) + setTextColor(R.id.widget_headline, palette.headline) + setTextColor(R.id.widget_usage, palette.usage) + setTextColor(R.id.widget_updated, palette.updated) + setOnClickPendingIntent(R.id.widget_root, open) + // RemoteViews can only set a background through the View setter it reflects on. + setInt(R.id.widget_root, "setBackgroundResource", palette.background(transparency)) + } + manager.updateAppWidget(ids, views) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt new file mode 100644 index 00000000..663e4fc8 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt @@ -0,0 +1,583 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +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.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.FastOutSlowInEasing +import kotlinx.coroutines.launch +import org.json.JSONObject + +/** Tool output past this is cut on the phone; the desktop shows all of it. */ +private const val MAX_TOOL_OUTPUT = 4000 + +/** A task whose agent runs in the desktop's built-in chat, as in the phone web UI's ChatDetail. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + agent: RemoteAgent?, + agentId: String, + state: ConnectionState, + client: RemoteClient, + quickReplies: List, + sendQuickReplies: Boolean = false, + promptHistory: PromptHistoryStore? = null, + pageLabel: String? = null, + onBack: () -> Unit, + onPair: () -> Unit, +) { + val chatFlow = remember(agentId) { client.watchChat(agentId) } + DisposableEffect(agentId) { onDispose { client.unwatchChat(agentId) } } + val chat by chatFlow.collectAsState() + var closing by remember { mutableStateOf(false) } + val agentName = agent?.agentName ?: "The agent" + val canSend = state.canControl && state.status == ConnectionStatus.CONNECTED + + if (closing && agent != null) { + CloseTaskDialog( + taskName = agent.taskName, + close = { force -> client.closeTask(agent.taskId, force) }, + onDismiss = { closing = false }, + onClosed = { + closing = false + onBack() + }, + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + navigationIcon = { + TextButton(onClick = onBack) { + Text("Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Column { + Text( + agent?.taskName ?: "Chat", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + (chat?.let { chatStatusLabel(it) } ?: statusLabel(state)) + (pageLabel?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + }, + actions = { + if (agent != null && state.canControl) { + TextButton(onClick = { closing = true }) { + Text("Close", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold) + } + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + ) { + val current = chat + if (current?.status == "closed") { + Banner("This chat has stopped. Reconnect it on your computer to continue.") + } + current?.error?.let { Banner(it, error = true) } + if (current == null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("Loading the conversation…", color = AppTheme.extra.textMuted) + } + } else { + ChatTranscript( + chat = current, + agentName = agentName, + canRespond = canSend, + respond = { request, decision, answers -> + val params = JSONObject() + .put("requestId", request.id) + .put("decision", decision) + .put("answers", JSONObject(answers)) + client.sendChatAction(agentId, "respond", params) + }, + modifier = Modifier.weight(1f), + ) + } + if (!state.canControl) { + Spacer(Modifier.height(8.dp)) + PairBanner(onPair) + Spacer(Modifier.height(8.dp)) + } else { + ChatComposer( + draftKey = agentId, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, + promptHistory = promptHistory, + enabled = canSend && current != null && current.status != "closed", + working = current?.status == "working", + send = { text -> client.sendChatAction(agentId, "send", JSONObject().put("text", text)) }, + stop = { client.sendChatAction(agentId, "interrupt") }, + ) + } + } + } +} + +private fun chatStatusLabel(chat: ChatState) = when (chat.status) { + "starting" -> "Starting…" + "working" -> "Working" + "closed" -> "Stopped" + else -> if (chat.requests.isNotEmpty()) "Needs input" else "Ready" +} + (chat.model?.let { " · $it" } ?: "") + +@Composable +private fun Banner(text: String, error: Boolean = false) { + Text( + text, + Modifier + .fillMaxWidth() + .background(if (error) MaterialTheme.colorScheme.errorContainer else AppTheme.extra.warningBannerBg) + .padding(horizontal = 16.dp, vertical = 10.dp), + color = if (error) MaterialTheme.colorScheme.onErrorContainer else AppTheme.extra.warningText, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun ChatTranscript( + chat: ChatState, + agentName: String, + canRespond: Boolean, + respond: suspend (ChatRequest, String, Map) -> Unit, + modifier: Modifier, +) { + val list = rememberLazyListState() + val count = chat.items.size + chat.requests.size + // Follow the conversation while the reader is at its end; leave them be if they scrolled up. + LaunchedEffect(count, chat.items.lastOrNull()?.text?.length) { + val last = list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + if (count > 0 && last >= list.layoutInfo.totalItemsCount - 3) list.animateScrollToItem(count - 1) + } + LazyColumn( + modifier = modifier.fillMaxWidth(), + state = list, + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(chat.items, key = { "item:${it.id}" }) { item -> + // New messages fade and rise in; existing ones slide when others arrive. + Box(Modifier.animateItem(fadeInSpec = tween(220), placementSpec = spring(stiffness = Spring.StiffnessMediumLow))) { + when (item.kind) { + "user" -> UserMessage(item.text) + "tool" -> ToolItem(item) + else -> SelectionContainer { + Text(item.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + } + } + items(chat.requests, key = { "request:${it.id}" }) { request -> + Box(Modifier.animateItem()) { RequestCard(request, agentName, canRespond, respond) } + } + if (chat.status == "working" && chat.requests.isEmpty()) { + item(key = "typing") { TypingIndicator(Modifier.animateItem()) } + } + } +} + +/** Three dots rising in turn while the agent works. */ +@Composable +private fun TypingIndicator(modifier: Modifier) { + val transition = rememberInfiniteTransition(label = "typing") + Row(modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(3) { i -> + val lift by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 + i * 150 + 1f at 300 + i * 150 + 0f at 600 + i * 150 + }, + ), + label = "dot$i", + ) + Box( + Modifier + .size(7.dp) + .graphicsLayer { + translationY = -lift * 5.dp.toPx() + alpha = 0.4f + 0.6f * lift + } + .background(AppTheme.extra.textMuted, CircleShape), + ) + } + } +} + +@Composable +private fun UserMessage(text: String) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + SelectionContainer { + Text( + text, + Modifier + .widthIn(max = 320.dp) + .background(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ToolItem(item: ChatItem) { + var open by rememberSaveable(item.id) { mutableStateOf(false) } + val activity = item.activity + val (mark, color) = when (activity?.status) { + "running" -> "…" to AppTheme.extra.textMuted + "failed" -> "✗" to MaterialTheme.colorScheme.error + "declined", "interrupted" -> "–" to AppTheme.extra.warningText + else -> "✓" to AppTheme.extra.success + } + val output = item.text.take(MAX_TOOL_OUTPUT) + Column( + Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, AppTheme.extra.borderSubtle), MaterialTheme.shapes.small) + .clickable(enabled = output.isNotBlank()) { open = !open } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(mark, color = color, fontWeight = FontWeight.Bold) + Text( + activity?.command ?: activity?.label ?: "Tool", + fontFamily = if (activity?.command != null) FontFamily.Monospace else null, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + maxLines = if (open) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (open) { + Text( + output + if (item.text.length > MAX_TOOL_OUTPUT) "\n… (more on your computer)" else "", + Modifier + .padding(top = 6.dp) + .horizontalScroll(rememberScrollState()), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + softWrap = false, + ) + } + } +} + +/** The desktop's RequestCard: an approval to allow or decline, or questions to answer. */ +@Composable +private fun RequestCard( + request: ChatRequest, + agentName: String, + canRespond: Boolean, + respond: suspend (ChatRequest, String, Map) -> Unit, +) { + val scope = rememberCoroutineScope() + var answers by remember(request.id) { mutableStateOf(mapOf()) } + var picked by remember(request.id) { mutableStateOf(mapOf>()) } + var busy by remember(request.id) { mutableStateOf(false) } + var error by remember(request.id) { mutableStateOf(null) } + var showDetails by remember(request.id) { mutableStateOf(false) } + val isQuestion = request.kind == "question" + + val haptic = LocalHapticFeedback.current + // The card breathes like a waiting agent card, so it reads as the thing to act on. + val glow by rememberInfiniteTransition(label = "requestGlow").animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), + label = "requestGlowAlpha", + ) + + fun submit(decision: String) { + haptic.performHapticFeedback(if (decision == "decline") HapticFeedbackType.Reject else HapticFeedbackType.Confirm) + busy = true + error = null + scope.launch { + try { + respond(request, decision, answers) + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + + Column( + Modifier + .fillMaxWidth() + .background(AppTheme.extra.cardBgAttention) + .border(BorderStroke(1.dp, AppTheme.extra.attentionBorder.copy(alpha = glow))) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + if (isQuestion) "$agentName needs your input" else "Approval needed" + (request.action?.let { ": $it" } ?: ""), + fontWeight = FontWeight.Bold, + color = AppTheme.extra.warningText, + ) + if (!isQuestion) { + Text(request.text, style = MaterialTheme.typography.bodyMedium) + request.details?.let { details -> + Text( + if (showDetails) "Hide details" else "Details", + Modifier.clickable { showDetails = !showDetails }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + ) + if (showDetails) { + Text(details, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp) + } + } + } + request.questions.forEach { q -> + Text(q.question, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + if (q.options.isNotEmpty()) { + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.options.forEach { option -> + val selected = if (q.multiSelect) picked[q.id].orEmpty().contains(option.label) else answers[q.id] == option.label + FilterChip( + selected = selected, + enabled = !busy, + onClick = { + if (q.multiSelect) { + val previous = picked[q.id].orEmpty() + val next = if (option.label in previous) previous - option.label else previous + option.label + picked = picked + (q.id to next) + answers = answers + (q.id to next.joinToString(", ")) + } else { + answers = answers + (q.id to option.label) + } + }, + label = { Text(option.label) }, + ) + } + } + } + OutlinedTextField( + value = answers[q.id].orEmpty(), + onValueChange = { + answers = answers + (q.id to it) + picked = picked + (q.id to emptyList()) + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(if (q.options.isEmpty()) "Your answer" else "Or type your own answer") }, + visualTransformation = if (q.isSecret) PasswordVisualTransformation() else VisualTransformation.None, + singleLine = true, + enabled = !busy, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val answered = request.questions.all { answers[it.id]?.isNotBlank() == true } + Button( + onClick = { submit("accept") }, + enabled = canRespond && !busy && answered, + colors = if (request.defaultToNo) ButtonDefaults.outlinedButtonColors() else ButtonDefaults.buttonColors(), + ) { Text(if (isQuestion) "Submit answers" else "Allow once") } + if (!isQuestion && request.canAlwaysAllow) { + OutlinedButton(onClick = { submit("accept-always") }, enabled = canRespond && !busy) { Text("Always allow") } + } + if (!isQuestion) { + OutlinedButton(onClick = { submit("decline") }, enabled = canRespond && !busy) { Text("Decline") } + } + if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + if (!isQuestion && request.canAlwaysAllow) { + request.alwaysAllowNote?.let { + Text("Always allow will $it.", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } +} + +@Composable +private fun ChatComposer( + draftKey: String, + quickReplies: List, + sendQuickReplies: Boolean = false, + promptHistory: PromptHistoryStore? = null, + enabled: Boolean, + working: Boolean, + send: suspend (String) -> Unit, + stop: suspend () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(draftKey) { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var showHistory by remember { mutableStateOf(false) } + + fun run(action: suspend () -> Unit, clearDraft: Boolean) { + busy = true + error = null + scope.launch { + try { + action() + if (clearDraft) draft = "" + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + QuickReplies(quickReplies, enabled = enabled && !busy) { + if (sendQuickReplies) run({ send(it) }, clearDraft = false) + else draft = appendToDraft(draft, it) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Message the agent") }, + maxLines = 5, + enabled = enabled, + ) + VoiceInputButton(enabled = enabled && !busy) { draft = appendToDraft(draft, it) } + if (promptHistory != null) { + OutlinedButton( + onClick = { showHistory = true }, + enabled = enabled && !busy && promptHistory.history(draftKey).isNotEmpty(), + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.History, contentDescription = "Recent messages") + } + } + if (working && draft.isBlank()) { + OutlinedButton(onClick = { run(stop, clearDraft = false) }, enabled = enabled && !busy) { + Icon(Icons.Filled.Stop, contentDescription = "Stop") + } + } else { + Button( + onClick = { + val text = draft.trim() + run({ send(text); promptHistory?.record(draftKey, text) }, clearDraft = true) + }, + enabled = enabled && !busy && draft.isNotBlank(), + ) { Text("Send") } + } + } + if (showHistory) { + val history = remember(showHistory) { promptHistory?.history(draftKey).orEmpty() } + PromptHistoryDialog( + history = history, + onPick = { + draft = appendToDraft(draft, it) + showHistory = false + }, + onDismiss = { showHistory = false }, + ) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatState.kt b/android/app/src/main/java/com/parallelcode/phone/ChatState.kt new file mode 100644 index 00000000..c28eb0a4 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ChatState.kt @@ -0,0 +1,84 @@ +package com.parallelcode.phone + +import org.json.JSONArray +import org.json.JSONObject + +// Mirrors AgentChatState in electron/shared/agent-chat-types.ts: the parts the phone shows. + +data class ChatActivity(val type: String, val label: String, val status: String, val command: String?) + +data class ChatItem(val id: String, val kind: String, val text: String, val activity: ChatActivity?) + +data class ChatOption(val label: String, val description: String) + +data class ChatQuestion( + val id: String, + val question: String, + val isSecret: Boolean, + val multiSelect: Boolean, + val options: List, +) + +/** [id] keeps the desktop's type (string or number): the respond action must send it back as is. */ +data class ChatRequest( + val id: Any, + val kind: String, + val text: String, + val action: String?, + val details: String?, + val questions: List, + val defaultToNo: Boolean, + val canAlwaysAllow: Boolean, + val alwaysAllowNote: String?, +) + +data class ChatState( + val status: String, + val model: String?, + val items: List, + val requests: List, + val error: String?, +) + +private fun JSONObject.stringOrNull(key: String): String? = + if (has(key) && !isNull(key)) optString(key) else null + +private inline fun JSONArray?.mapObjects(transform: (JSONObject) -> T): List = + if (this == null) emptyList() else List(length()) { transform(getJSONObject(it)) } + +fun parseChatState(json: JSONObject) = ChatState( + status = json.optString("status", "starting"), + model = json.stringOrNull("model"), + items = json.optJSONArray("items").mapObjects { item -> + ChatItem( + id = item.optString("id"), + kind = item.optString("kind"), + text = item.optString("text"), + activity = item.optJSONObject("activity")?.let { + ChatActivity(it.optString("type"), it.optString("label"), it.optString("status"), it.stringOrNull("command")) + }, + ) + }, + requests = json.optJSONArray("requests").mapObjects { request -> + ChatRequest( + id = request.get("id"), + kind = request.optString("kind"), + text = request.optString("text"), + action = request.stringOrNull("action"), + details = request.stringOrNull("details"), + questions = request.optJSONArray("questions").mapObjects { q -> + ChatQuestion( + id = q.optString("id"), + question = q.optString("question"), + isSecret = q.optBoolean("isSecret"), + multiSelect = q.optBoolean("multiSelect"), + options = q.optJSONArray("options").mapObjects { ChatOption(it.optString("label"), it.optString("description")) }, + ) + }, + defaultToNo = request.optBoolean("defaultToNo"), + canAlwaysAllow = request.optBoolean("canAlwaysAllow"), + alwaysAllowNote = request.stringOrNull("alwaysAllowNote"), + ) + }, + error = json.stringOrNull("error"), +) diff --git a/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt new file mode 100644 index 00000000..ae89c0c2 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt @@ -0,0 +1,126 @@ +package com.parallelcode.phone + +import android.app.Activity +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** Saved replies from Settings: tapping one puts it in the draft, ready to send or edit. */ +@Composable +fun QuickReplies(replies: List, enabled: Boolean, onPick: (String) -> Unit) { + if (replies.isEmpty()) return + val haptic = LocalHapticFeedback.current + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + replies.forEach { reply -> + AssistChip( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onPick(reply) + }, + enabled = enabled, + label = { Text(reply, maxLines = 1) }, + ) + } + } +} + +/** Adds [text] to a draft, as typing it after what is already there would. */ +fun appendToDraft(draft: String, text: String): String = when { + draft.isBlank() -> text + draft.endsWith(" ") || draft.endsWith("\n") -> draft + text + else -> "$draft $text" +} + +/** Messages this agent was sent before, newest first: picking one puts it back in the draft. */ +@Composable +fun PromptHistoryDialog( + history: List, + onPick: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { Text("Recent messages") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + history.forEach { message -> + Text( + message, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(message) } + .padding(horizontal = 4.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + +/** + * Dictate into the draft with Android's speech recognizer. Hidden when the phone has none; the + * recognizer shows its own listening UI, so the app needs no microphone permission. + */ +@Composable +fun VoiceInputButton(enabled: Boolean, modifier: Modifier = Modifier, onText: (String) -> Unit) { + val context = LocalContext.current + val intent = remember { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak your reply") + } + val available = remember { intent.resolveActivity(context.packageManager) != null } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()?.let(onText) + } + if (!available) return + OutlinedButton( + onClick = { launcher.launch(intent) }, + enabled = enabled, + modifier = modifier, + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.Mic, contentDescription = "Dictate a reply") + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt b/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt new file mode 100644 index 00000000..f9852ead --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt @@ -0,0 +1,37 @@ +package com.parallelcode.phone + +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +/** The "Connect Phone" QR code on the desktop: `http://host:port/?token=`. */ +data class ConnectionLink(val baseUrl: String, val token: String) { + val webSocketUrl: String + get() = "ws" + baseUrl.removePrefix("http") + "/ws" + + companion object { + // The server rejects longer tokens in its WebSocket auth message. + private const val MAX_TOKEN_LENGTH = 200 + + fun parse(raw: String): ConnectionLink? { + val uri = try { + URI(raw.trim()) + } catch (_: URISyntaxException) { + return null + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return null + val authority = uri.rawAuthority?.takeIf { uri.host != null } ?: return null + val token = uri.rawQuery + ?.split('&') + ?.firstNotNullOfOrNull { part -> + val (key, value) = part.split('=', limit = 2).let { it[0] to it.getOrNull(1) } + value?.takeIf { key == "token" } + } + ?.let { URLDecoder.decode(it, "UTF-8") } + ?.takeIf { it.isNotEmpty() && it.length <= MAX_TOKEN_LENGTH } + ?: return null + return ConnectionLink("$scheme://$authority", token) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt b/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt new file mode 100644 index 00000000..f4c40b70 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt @@ -0,0 +1,137 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject + +/** A desktop this phone has linked to, by its Remote Access address. */ +data class SavedComputer( + val baseUrl: String, + val token: String, + val pairedToken: String?, + /** A name the user gave this computer; null means it has none. */ + val alias: String? = null, +) { + /** What the phone shows for this computer: its name, or "192.168.1.20:7777". */ + val label: String get() = alias?.takeIf { it.isNotBlank() } ?: baseUrl.substringAfter("://") +} + +/** + * The desktops this phone is linked to, and which one it uses. The view-only token comes from the + * QR code; the paired token is minted by entering the desktop's PIN and is the one allowed to type. + * Computers are keyed by address. App-private storage with backups disabled in the manifest. + */ +class CredentialStore(private val prefs: SharedPreferences) { + + init { + migrateSingleComputer() + } + + val computers: List + get() { + val array = runCatching { JSONArray(prefs.getString(KEY_COMPUTERS, "[]")) }.getOrElse { JSONArray() } + return List(array.length()) { i -> + val c = array.getJSONObject(i) + SavedComputer( + c.getString("baseUrl"), + c.getString("token"), + if (c.isNull("pairedToken")) null else c.optString("pairedToken").ifEmpty { null }, + // Written by newer builds; missing on older ones. + if (c.isNull("alias")) null else c.optString("alias").ifEmpty { null }, + ) + } + } + + private val active: SavedComputer? + get() = prefs.getString(KEY_ACTIVE, null)?.let { url -> computers.firstOrNull { it.baseUrl == url } } + + val link: ConnectionLink? + get() = active?.let { ConnectionLink(it.baseUrl, it.token) } + + val pairedToken: String? + get() = active?.pairedToken + + /** + * Use [link]'s computer, adding it if new. A new QR token for a known address may belong to a + * different computer now, so it drops that address's paired token, but keeps its name. + */ + fun saveLink(link: ConnectionLink) { + val alias = computers.firstOrNull { it.baseUrl == link.baseUrl }?.alias + val others = computers.filter { it.baseUrl != link.baseUrl } + write(others + SavedComputer(link.baseUrl, link.token, null, alias), active = link.baseUrl) + } + + /** Switch to a saved computer. */ + fun select(baseUrl: String) { + if (computers.any { it.baseUrl == baseUrl }) prefs.edit { putString(KEY_ACTIVE, baseUrl) } + } + + fun savePairedToken(token: String) = updateActive { it.copy(pairedToken = token) } + + fun clearPairedToken() = updateActive { it.copy(pairedToken = null) } + + /** Name a saved computer; a blank name clears the one it had. */ + fun rename(baseUrl: String, alias: String?) { + val current = prefs.getString(KEY_ACTIVE, null) + write( + computers.map { if (it.baseUrl == baseUrl) it.copy(alias = alias?.trim()?.takeIf { it.isNotEmpty() }) else it }, + active = current, + ) + } + + /** Forget a saved computer; forgetting the one in use leaves none selected. */ + fun remove(baseUrl: String) { + val current = prefs.getString(KEY_ACTIVE, null) + write(computers.filter { it.baseUrl != baseUrl }, active = current.takeIf { it != baseUrl }) + } + + /** Forget the computer in use. */ + fun clear() { + prefs.getString(KEY_ACTIVE, null)?.let(::remove) + } + + private fun updateActive(change: (SavedComputer) -> SavedComputer) { + val url = prefs.getString(KEY_ACTIVE, null) ?: return + write(computers.map { if (it.baseUrl == url) change(it) else it }, active = url) + } + + private fun write(list: List, active: String?) { + val array = JSONArray() + list.forEach { + array.put( + JSONObject() + .put("baseUrl", it.baseUrl) + .put("token", it.token) + .put("pairedToken", it.pairedToken ?: JSONObject.NULL) + .put("alias", it.alias ?: JSONObject.NULL), + ) + } + prefs.edit { + putString(KEY_COMPUTERS, array.toString()) + if (active == null) remove(KEY_ACTIVE) else putString(KEY_ACTIVE, active) + } + } + + /** Earlier versions kept one computer in flat keys; carry it over, pairing included. */ + private fun migrateSingleComputer() { + val baseUrl = prefs.getString(LEGACY_BASE_URL, null) ?: return + val token = prefs.getString(LEGACY_TOKEN, null) + if (token != null && !prefs.contains(KEY_COMPUTERS)) { + write(listOf(SavedComputer(baseUrl, token, prefs.getString(LEGACY_PAIRED_TOKEN, null))), active = baseUrl) + } + prefs.edit { + remove(LEGACY_BASE_URL) + remove(LEGACY_TOKEN) + remove(LEGACY_PAIRED_TOKEN) + } + } + + private companion object { + const val KEY_COMPUTERS = "computers" + const val KEY_ACTIVE = "active" + const val LEGACY_BASE_URL = "baseUrl" + const val LEGACY_TOKEN = "token" + const val LEGACY_PAIRED_TOKEN = "pairedToken" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt b/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt new file mode 100644 index 00000000..0a7bfe63 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt @@ -0,0 +1,207 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** Diff lines shown per file on the phone; the desktop shows the rest. */ +private const val MAX_FILE_LINES = 1500 + +/** The task's changes against its base branch, file by file, as the desktop's diff view. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiffPane(taskId: String, client: RemoteClient, modifier: Modifier) { + var reload by remember { mutableIntStateOf(0) } + var files by remember(taskId) { mutableStateOf?>(null) } + var truncated by remember(taskId) { mutableStateOf(false) } + var unsupported by remember(taskId) { mutableStateOf(false) } + var error by remember(taskId) { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + + LaunchedEffect(taskId, reload) { + loading = true + error = null + try { + val result = client.fetchDiff(taskId) + files = parseUnifiedDiff(result.diff) + truncated = result.truncated + unsupported = result.unsupported + } catch (e: ApiException) { + error = if (e.status == 403 || e.status == 404) "Update Parallel Code on your computer to see changes here." else e.message + } finally { + loading = false + } + } + + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val current = files + Text( + when { + current == null -> "" + unsupported -> "" + current.isEmpty() -> "No changes" + else -> "${current.size} file${if (current.size == 1) "" else "s"} · +${current.sumOf { it.added }} −${current.sumOf { it.removed }}" + }, + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + TextButton(onClick = { reload++ }, enabled = !loading) { Text("Refresh") } + } + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + error?.let { + Text(it, Modifier.padding(16.dp), color = MaterialTheme.colorScheme.error) + return@Column + } + val current = files ?: run { + if (loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(strokeWidth = 2.dp) } + } + return@Column + } + if (unsupported) { + Text( + "This task works directly in the project folder, so there is no branch to compare.", + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + return@Column + } + if (truncated) { + Text( + "This diff is too large for the phone; showing the start.", + Modifier + .fillMaxWidth() + .background(AppTheme.extra.warningBannerBg) + .padding(horizontal = 16.dp, vertical = 8.dp), + color = AppTheme.extra.warningText, + style = MaterialTheme.typography.bodySmall, + ) + } + // Pull down to refresh, as on the agent list. + PullToRefreshBox(isRefreshing = loading, onRefresh = { reload++ }, modifier = Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize()) { + items(current, key = { it.path }) { file -> DiffFileRow(file) } + } + } + } +} + +@Composable +private fun DiffFileRow(file: DiffFile) { + var open by rememberSaveable(file.path) { mutableStateOf(false) } + Column { + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = !file.binary) { open = !open } + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (open) "▾" else "▸", color = AppTheme.extra.textMuted) + Text( + file.path, + Modifier.weight(1f), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.StartEllipsis, + ) + if (file.binary) { + Text("binary", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } else { + Text("+${file.added}", color = AppTheme.extra.success, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text("−${file.removed}", color = MaterialTheme.colorScheme.error, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + AnimatedVisibility(visible = open, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + DiffLines(file) + } + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + } +} + +@Composable +private fun DiffLines(file: DiffFile) { + val added = AppTheme.extra.success + val removed = MaterialTheme.colorScheme.error + val hunk = AppTheme.extra.textMuted + Box( + Modifier + .fillMaxWidth() + .background(AppTheme.extra.inputBg) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Column { + file.lines.take(MAX_FILE_LINES).forEach { line -> + val (fg, bg) = when { + line.startsWith("@@") -> hunk to Color.Transparent + line.startsWith("+") -> added to added.copy(alpha = 0.12f) + line.startsWith("-") -> removed to removed.copy(alpha = 0.12f) + else -> MaterialTheme.colorScheme.onSurface to Color.Transparent + } + Text( + line.ifEmpty { " " }, + Modifier.background(bg), + color = fg, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + softWrap = false, + ) + } + if (file.lines.size > MAX_FILE_LINES) { + Text("… ${file.lines.size - MAX_FILE_LINES} more lines on your computer", color = hunk, fontSize = 11.sp) + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/LookPalettes.kt b/android/app/src/main/java/com/parallelcode/phone/LookPalettes.kt new file mode 100644 index 00000000..4ffdb10c --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/LookPalettes.kt @@ -0,0 +1,817 @@ +package com.parallelcode.phone + +// GENERATED FILE - DO NOT EDIT. +// +// Regenerate with: node scripts/generate-android-looks.mjs +// Source of truth: src/lib/look.ts (ids, labels, descriptions, tone) and +// src/styles.css (colors and radii), matching the desktop app. +// +// The desktop themes set only the variables they change and inherit the rest +// from :root; every palette below is that cascade fully resolved, so the phone +// has no fallbacks of its own. Gradients are flattened to their middle stop +// because the phone draws flat surfaces; everything else is the exact value. + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * One desktop look preset: every color and radius the phone draws with. + * + * Read these through [LookPresets] rather than constructing one. + */ +@Immutable +data class LookPalette( + val id: String, + val label: String, + val description: String, + /** True for the desktop's light presets. */ + val dark: Boolean, + // Surfaces and state, from the desktop's --bg family. + val bg: Color, + val bgElevated: Color, + val bgInput: Color, + val bgHover: Color, + val bgSelected: Color, + // Borders. + val border: Color, + val borderSubtle: Color, + val borderFocus: Color, + // Text. + val fg: Color, + val fgMuted: Color, + val fgSubtle: Color, + // Accent family. + val accent: Color, + val accentHover: Color, + val accentText: Color, + val link: Color, + // Status hues: success, error and warning plus the secondary + // review and info hues the desktop keeps off the accent. + val success: Color, + val error: Color, + val warning: Color, + val review: Color, + val info: Color, + // Panels. + val islandBg: Color, + val islandBorder: Color, + val containerBg: Color, + val panelBg: Color, + // Diff line tints. + val diffAddBg: Color, + val diffRemoveBg: Color, + // The desktop's corner radius scale, in dp. + val radiusXs: Dp, + val radiusSm: Dp, + val radiusMd: Dp, + val radiusLg: Dp, + /** Corner radius of an island or card, `--island-radius`. */ + val radiusIsland: Dp, + /** + * Id of the [TERMINAL_THEMES] entry this look draws terminals with, so agent + * output follows the look the way it does on the desktop. + */ + val terminalThemeId: String, +) + +/** + * One of the desktop's terminal color sets: a default text color and the 16 ANSI + * colors, in the order `ansi[c]` is indexed in TerminalStyle.kt. + */ +@Immutable +data class TerminalTheme( + val id: String, + val foreground: Int, + val ansi: List, +) + +private val TerminalLight = + TerminalTheme( + id = "light", + foreground = -14736599, + ansi = + listOf( + -14407378, + -3202514, + -15637719, + -7705344, + -16428882, + -8236833, + -14975869, + -9537663, + -11050902, + -6025690, + -15040713, + -10273791, + -16160294, + -10077766, + -13528406, + -14736599, + ), + ) + +private val TerminalObsidian = + TerminalTheme( + id = "obsidian", + foreground = -1776412, + ansi = + listOf( + -13750738, + -2061162, + -6764114, + -2113138, + -7359524, + -4083480, + -7419447, + -3552823, + -8026747, + -1400662, + -5382975, + -1387608, + -5716504, + -2964240, + -5842214, + -1184275, + ), + ) + +private val TerminalNoir = + TerminalTheme( + id = "noir", + foreground = -1513233, + ansi = + listOf( + -12895672, + -1011830, + -6764114, + -2114941, + -7356963, + -1858602, + -7548976, + -3882032, + -7303010, + -677724, + -5382975, + -1388379, + -5648918, + -1131035, + -5841700, + -855305, + ), + ) + +private val TerminalIslandsDark = + TerminalTheme( + id = "islands-dark", + foreground = -4407612, + ansi = + listOf( + -13618118, + -568220, + -9786509, + -1531074, + -9593352, + -4351256, + -9389625, + -4407612, + -7697004, + -34683, + -7879282, + -737687, + -7161857, + -3034637, + -7220516, + -1117964, + ), + ) + +/** Every terminal theme, by id. */ +val ALL_TERMINAL_THEMES: Map = + listOf( + TerminalLight, + TerminalObsidian, + TerminalNoir, + TerminalIslandsDark, + ).associateBy { it.id } + +private val Noir = + LookPalette( + id = "noir", + label = "Noir", + description = "Ink-dark surfaces, soft lilac, and quiet controls that follow your focus", + dark = true, + bg = Color(0xFF0C0C10), + bgElevated = Color(0xFF1C1C24), + bgInput = Color(0xFF202029), + bgHover = Color(0xFF2A2935), + bgSelected = Color(0xFF343043), + border = Color(0xFF33323E), + borderSubtle = Color(0xFF25252E), + borderFocus = Color(0xFFB7A5F5), + fg = Color(0xFFE8E8EF), + fgMuted = Color(0xFFB1B0BF), + fgSubtle = Color(0xFF9392A3), + accent = Color(0xFFB7A5F5), + accentHover = Color(0xFFCBBCFB), + accentText = Color(0xFF191421), + link = Color(0xFF9CC8EA), + success = Color(0xFF98C9AE), + error = Color(0xFFF08F8A), + warning = Color(0xFFDFBA83), + review = Color(0xFF8FBDDD), + info = Color(0xFFC4C3D0), + islandBg = Color(0xFF15151B), + islandBorder = Color(0xFF33323E), + containerBg = Color(0xFF15151B), + panelBg = Color(0xFF15151B), + diffAddBg = Color(0x2698C9AE), + diffRemoveBg = Color(0x26F08F8A), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 10.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Obsidian = + LookPalette( + id = "obsidian", + label = "Obsidian", + description = "Neutral charcoal surfaces, warm amber accent, and clean typography", + dark = true, + bg = Color(0xFF171717), + bgElevated = Color(0xFF242424), + bgInput = Color(0xFF262626), + bgHover = Color(0xFF303030), + bgSelected = Color(0xFF353535), + border = Color(0xFF333333), + borderSubtle = Color(0xFF292929), + borderFocus = Color(0xFFC4A77D), + fg = Color(0xFFEDEDED), + fgMuted = Color(0xFFB5B5B5), + fgSubtle = Color(0xFF919191), + accent = Color(0xFFC4A77D), + accentHover = Color(0xFFD6BD96), + accentText = Color(0xFF1E1B16), + link = Color(0xFFD6BD96), + success = Color(0xFF98C9AE), + error = Color(0xFFEAA0AA), + warning = Color(0xFFF29B70), + review = Color(0xFFC1B0E8), + info = Color(0xFFC9C9C9), + islandBg = Color(0xFF1E1E1E), + islandBorder = Color(0xFF303030), + containerBg = Color(0xFF1E1E1E), + panelBg = Color(0xFF1E1E1E), + diffAddBg = Color(0x1F98C9AE), + diffRemoveBg = Color(0x1FEAA0AA), + radiusXs = 4.dp, + radiusSm = 4.dp, + radiusMd = 0.dp, + radiusLg = 0.dp, + radiusIsland = 0.dp, + terminalThemeId = "obsidian", + ) + +private val ObsidianLight = + LookPalette( + id = "obsidian-light", + label = "Obsidian Light", + description = "Obsidian on warm paper neutrals with a bronze accent", + dark = false, + bg = Color(0xFFF4F4F2), + bgElevated = Color(0xFFFFFFFF), + bgInput = Color(0xFFF2F2F0), + bgHover = Color(0xFFEBEBE8), + bgSelected = Color(0xFFE2E1DC), + border = Color(0xFFDDDCD8), + borderSubtle = Color(0xFFE9E8E4), + borderFocus = Color(0xFF8A6433), + fg = Color(0xFF1F1F1F), + fgMuted = Color(0xFF555555), + fgSubtle = Color(0xFF6E6E6E), + accent = Color(0xFF8A6433), + accentHover = Color(0xFF75532A), + accentText = Color(0xFFFFFFFF), + link = Color(0xFF75532A), + success = Color(0xFF2F7D4F), + error = Color(0xFFB3383F), + warning = Color(0xFFAD4E00), + review = Color(0xFF6D4FC2), + info = Color(0xFF5E5E5E), + islandBg = Color(0xFFFFFFFF), + islandBorder = Color(0xFFDDDCD8), + containerBg = Color(0xFFFFFFFF), + panelBg = Color(0xFFFFFFFF), + diffAddBg = Color(0x1F2F7D4F), + diffRemoveBg = Color(0x1AB3383F), + radiusXs = 0.dp, + radiusSm = 0.dp, + radiusMd = 0.dp, + radiusLg = 0.dp, + radiusIsland = 0.dp, + terminalThemeId = "light", + ) + +private val IslandsDark = + LookPalette( + id = "islands-dark", + label = "Islands Dark", + description = "JetBrains-inspired dark panels on a tinted frame", + dark = true, + bg = Color(0xFF2B323D), + bgElevated = Color(0xFF181A1D), + bgInput = Color(0xFF1E2126), + bgHover = Color(0xFF23262C), + bgSelected = Color(0xFF25324D), + border = Color(0xFF2C2F34), + borderSubtle = Color(0xFF232529), + borderFocus = Color(0xFF286CF5), + fg = Color(0xFFBCBEC4), + fgMuted = Color(0xFF9A9DA4), + fgSubtle = Color(0xFF7A7E85), + accent = Color(0xFF286CF5), + accentHover = Color(0xFF6D9DF8), + accentText = Color(0xFFFFFFFF), + link = Color(0xFF6D9DF8), + success = Color(0xFF6AAB73), + error = Color(0xFFF75464), + warning = Color(0xFFE8A33E), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF181A1D), + islandBorder = Color(0xFF2C2F34), + containerBg = Color(0xFF181A1D), + panelBg = Color(0xFF181A1D), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "islands-dark", + ) + +private val IslandsLight = + LookPalette( + id = "islands-light", + label = "Islands Light", + description = "JetBrains-inspired light panels on a soft tinted frame", + dark = false, + bg = Color(0xFFCDD6E4), + bgElevated = Color(0xFFFFFFFF), + bgInput = Color(0xFFF3F4F6), + bgHover = Color(0xFFE8EAEF), + bgSelected = Color(0xFFCFE1FF), + border = Color(0xFFD3D5DB), + borderSubtle = Color(0xFFE3E5EA), + borderFocus = Color(0xFF2C6DEF), + fg = Color(0xFF1F2329), + fgMuted = Color(0xFF5E6066), + fgSubtle = Color(0xFF8D9199), + accent = Color(0xFF2C6DEF), + accentHover = Color(0xFF5A8DFF), + accentText = Color(0xFFFFFFFF), + link = Color(0xFF2C6DEF), + success = Color(0xFF369650), + error = Color(0xFFD62B40), + warning = Color(0xFFA98708), + review = Color(0xFF7C3AED), + info = Color(0xFF1D6FD6), + islandBg = Color(0xFFFFFFFF), + islandBorder = Color(0xFFD3D5DB), + containerBg = Color(0xFFFFFFFF), + panelBg = Color(0xFFFFFFFF), + diffAddBg = Color(0x24369650), + diffRemoveBg = Color(0x1FD62B40), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "light", + ) + +private val Minimal = + LookPalette( + id = "minimal", + label = "Minimal", + description = "Flat monochrome with warm off-white accent", + dark = true, + bg = Color(0xFF191916), + bgElevated = Color(0xFF1E1E1B), + bgInput = Color(0xFF252520), + bgHover = Color(0x12FFFFFF), + bgSelected = Color(0x38C8BFA0), + border = Color(0xFF333230), + borderSubtle = Color(0xFF2A2925), + borderFocus = Color(0xFFE8DEC5), + fg = Color(0xFFECECEC), + fgMuted = Color(0xFFC0C0C0), + fgSubtle = Color(0xFF999999), + accent = Color(0xFFC8BFA0), + accentHover = Color(0xFFE6DEC5), + accentText = Color(0xFF090909), + link = Color(0xFFE6DEC5), + success = Color(0xFF8AAA7A), + error = Color(0xFFD05656), + warning = Color(0xFFD4AA58), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF1B1B18), + islandBorder = Color(0xFF2A2925), + containerBg = Color(0xFF21211D), + panelBg = Color(0xFF252520), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 10.dp, + terminalThemeId = "noir", + ) + +private val Graphite = + LookPalette( + id = "graphite", + label = "Graphite", + description = "Cool neon blue with subtle glow", + dark = true, + bg = Color(0xFF141C26), + bgElevated = Color(0xFF1C2630), + bgInput = Color(0xFF19232E), + bgHover = Color(0xFF253240), + bgSelected = Color(0xFF1C3E55), + border = Color(0xFF2E3E50), + borderSubtle = Color(0xFF222E3A), + borderFocus = Color(0xFF2EC8FF), + fg = Color(0xFFD7E4F0), + fgMuted = Color(0xFF9BB0C3), + fgSubtle = Color(0xFF678197), + accent = Color(0xFF2EC8FF), + accentHover = Color(0xFF59D7FF), + accentText = Color(0xFF031018), + link = Color(0xFF73DFFF), + success = Color(0xFF2FD198), + error = Color(0xFFFF5F73), + warning = Color(0xFFFFC569), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF192028), + islandBorder = Color(0xFF2E3E50), + containerBg = Color(0xFF161E28), + panelBg = Color(0xFF1C2630), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Midnight = + LookPalette( + id = "midnight", + label = "Midnight", + description = "Graphite with pure black terminals", + dark = true, + bg = Color(0xFF141C26), + bgElevated = Color(0xFF1C2630), + bgInput = Color(0xFF19232E), + bgHover = Color(0xFF253240), + bgSelected = Color(0xFF1C3E55), + border = Color(0xFF2E3E50), + borderSubtle = Color(0xFF222E3A), + borderFocus = Color(0xFF2EC8FF), + fg = Color(0xFFD7E4F0), + fgMuted = Color(0xFF9BB0C3), + fgSubtle = Color(0xFF678197), + accent = Color(0xFF2EC8FF), + accentHover = Color(0xFF59D7FF), + accentText = Color(0xFF031018), + link = Color(0xFF73DFFF), + success = Color(0xFF2FD198), + error = Color(0xFFFF5F73), + warning = Color(0xFFFFC569), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF192028), + islandBorder = Color(0xFF2E3E50), + containerBg = Color(0xFF161E28), + panelBg = Color(0xFF000000), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Classic = + LookPalette( + id = "classic", + label = "Classic", + description = "Original dark utilitarian look", + dark = true, + bg = Color(0xFF161618), + bgElevated = Color(0xFF2D2E32), + bgInput = Color(0xFF2D2E32), + bgHover = Color(0xFF3A3C40), + bgSelected = Color(0xFF3E508A), + border = Color(0xFF393B3F), + borderSubtle = Color(0xFF2D2E32), + borderFocus = Color(0xFF4267FF), + fg = Color(0xFFCCCDD2), + fgMuted = Color(0xFF8B8D93), + fgSubtle = Color(0xFF767980), + accent = Color(0xFF4267FF), + accentHover = Color(0xFF3A5BFF), + accentText = Color(0xFFFFFFFF), + link = Color(0xFF6F8EFF), + success = Color(0xFF0DBC79), + error = Color(0xFFF75464), + warning = Color(0xFFF2C55C), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF242428), + islandBorder = Color(0xFF393B3F), + containerBg = Color(0xFF242428), + panelBg = Color(0xFF2D2E32), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 10.dp, + terminalThemeId = "noir", + ) + +private val Indigo = + LookPalette( + id = "indigo", + label = "Indigo", + description = "Deep indigo base with electric violet accents", + dark = true, + bg = Color(0xFF171C30), + bgElevated = Color(0xFF1C2038), + bgInput = Color(0xFF1A1E35), + bgHover = Color(0xFF262E50), + bgSelected = Color(0xFF2E3265), + border = Color(0xFF384270), + borderSubtle = Color(0xFF2B3456), + borderFocus = Color(0xFF7A78FF), + fg = Color(0xFFDEDDFF), + fgMuted = Color(0xFFB1B2DE), + fgSubtle = Color(0xFF8286B6), + accent = Color(0xFF7A78FF), + accentHover = Color(0xFF938FFF), + accentText = Color(0xFF090A18), + link = Color(0xFFA8A6FF), + success = Color(0xFF48D2A2), + error = Color(0xFFFF6C90), + warning = Color(0xFFFFCF7A), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF1A1E35), + islandBorder = Color(0xFF384270), + containerBg = Color(0xFF151822), + panelBg = Color(0xFF1C2038), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Ember = + LookPalette( + id = "ember", + label = "Ember", + description = "Warm copper highlights and contrast", + dark = true, + bg = Color(0xFF171416), + bgElevated = Color(0xFF211918), + bgInput = Color(0xFF1D1614), + bgHover = Color(0xFF31211D), + bgSelected = Color(0xFF552D1E), + border = Color(0xFF4E3831), + borderSubtle = Color(0xFF322420), + borderFocus = Color(0xFFFF944D), + fg = Color(0xFFF2DDD1), + fgMuted = Color(0xFFD5AB94), + fgSubtle = Color(0xFF9F7561), + accent = Color(0xFFFF944D), + accentHover = Color(0xFFFFAC73), + accentText = Color(0xFF201006), + link = Color(0xFFFFB47F), + success = Color(0xFF33C791), + error = Color(0xFFFF6A7F), + warning = Color(0xFFFFD27A), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF1E1514), + islandBorder = Color(0xFF4E3831), + containerBg = Color(0xFF1A1212), + panelBg = Color(0xFF211918), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Glacier = + LookPalette( + id = "glacier", + label = "Glacier", + description = "Clean teal accents with softer depth", + dark = true, + bg = Color(0xFF1D2932), + bgElevated = Color(0xFF232E3A), + bgInput = Color(0xFF202A34), + bgHover = Color(0xFF2F4050), + bgSelected = Color(0xFF28535D), + border = Color(0xFF3B5363), + borderSubtle = Color(0xFF2E424F), + borderFocus = Color(0xFF50E2D3), + fg = Color(0xFFE5EFF5), + fgMuted = Color(0xFFBED2DC), + fgSubtle = Color(0xFF92AEBB), + accent = Color(0xFF50E2D3), + accentHover = Color(0xFF76ECE0), + accentText = Color(0xFF041413), + link = Color(0xFF8CF2E8), + success = Color(0xFF4ED59F), + error = Color(0xFFFF6F88), + warning = Color(0xFFFFD57D), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF212B36), + islandBorder = Color(0xFF3B5363), + containerBg = Color(0xFF1C2730), + panelBg = Color(0xFF232E3A), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Zenburnesque = + LookPalette( + id = "zenburnesque", + label = "Zenburnesque", + description = "Warm sage and muted earth tones", + dark = true, + bg = Color(0xFF232220), + bgElevated = Color(0xFF2E2D2A), + bgInput = Color(0xFF2A2927), + bgHover = Color(0xFF3A3936), + bgSelected = Color(0xFF3F2E30), + border = Color(0xFF484640), + borderSubtle = Color(0xFF383634), + borderFocus = Color(0xFFCC9393), + fg = Color(0xFFDCDCCC), + fgMuted = Color(0xFFA0A090), + fgSubtle = Color(0xFF7F8F7F), + accent = Color(0xFFCC9393), + accentHover = Color(0xFFD8A8A8), + accentText = Color(0xFF1A0E0E), + link = Color(0xFFD8A8A8), + success = Color(0xFF7F9F7F), + error = Color(0xFFE37170), + warning = Color(0xFFF0DFAF), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF262523), + islandBorder = Color(0xFF484640), + containerBg = Color(0xFF212120), + panelBg = Color(0xFF2E2D2A), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val CatppuccinMocha = + LookPalette( + id = "catppuccin-mocha", + label = "Catppuccin Mocha", + description = "Pastel mauve accents on the cozy Catppuccin Mocha palette", + dark = true, + bg = Color(0xFF1A1A28), + bgElevated = Color(0xFF1E1E2E), + bgInput = Color(0xFF313244), + bgHover = Color(0xFF45475A), + bgSelected = Color(0xFF3B2F5A), + border = Color(0xFF45475A), + borderSubtle = Color(0xFF313244), + borderFocus = Color(0xFFCBA6F7), + fg = Color(0xFFCDD6F4), + fgMuted = Color(0xFFA6ADC8), + fgSubtle = Color(0xFF7F849C), + accent = Color(0xFFCBA6F7), + accentHover = Color(0xFFD6B8FA), + accentText = Color(0xFF1E1E2E), + link = Color(0xFF74C7EC), + success = Color(0xFFA6E3A1), + error = Color(0xFFF38BA8), + warning = Color(0xFFF9E2AF), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF1E1E2E), + islandBorder = Color(0xFF45475A), + containerBg = Color(0xFF181825), + panelBg = Color(0xFF1E1E2E), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 12.dp, + terminalThemeId = "noir", + ) + +private val Workbench = + LookPalette( + id = "workbench", + label = "Workbench", + description = "VS Code-inspired flat three-tier dark with cobalt blue", + dark = true, + bg = Color(0xFF181818), + bgElevated = Color(0xFF1F1F1F), + bgInput = Color(0xFF313131), + bgHover = Color(0xFF2A2D2E), + bgSelected = Color(0xFF04395E), + border = Color(0xFF2B2B2B), + borderSubtle = Color(0xFF1F1F1F), + borderFocus = Color(0xFF0078D4), + fg = Color(0xFFCCCCCC), + fgMuted = Color(0xFF969696), + fgSubtle = Color(0xFF6C6C6C), + accent = Color(0xFF0078D4), + accentHover = Color(0xFF1E90E0), + accentText = Color(0xFFFFFFFF), + link = Color(0xFF4DAAFC), + success = Color(0xFF89D185), + error = Color(0xFFF14C4C), + warning = Color(0xFFCCA700), + review = Color(0xFFC084FC), + info = Color(0xFF60A5FA), + islandBg = Color(0xFF252526), + islandBorder = Color(0xFF2B2B2B), + containerBg = Color(0xFF1F1F1F), + panelBg = Color(0xFF1F1F1F), + diffAddBg = Color(0x1A2FD198), + diffRemoveBg = Color(0x1AFF5F73), + radiusXs = 4.dp, + radiusSm = 6.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusIsland = 0.dp, + terminalThemeId = "noir", + ) + +/** Every preset, in the order the desktop lists them. */ +val ALL_LOOK_PALETTES: List = + listOf( + Noir, + Obsidian, + ObsidianLight, + IslandsDark, + IslandsLight, + Minimal, + Graphite, + Midnight, + Classic, + Indigo, + Ember, + Glacier, + Zenburnesque, + CatppuccinMocha, + Workbench, + ) diff --git a/android/app/src/main/java/com/parallelcode/phone/LookPresets.kt b/android/app/src/main/java/com/parallelcode/phone/LookPresets.kt new file mode 100644 index 00000000..43842261 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/LookPresets.kt @@ -0,0 +1,68 @@ +package com.parallelcode.phone + +/** + * Lookups over the generated [ALL_LOOK_PALETTES], mirroring the desktop's + * `presetsForTone`, `defaultPresetForTone` and `isLookPreset` in src/lib/look.ts. + * + * The palette list itself is generated from the desktop, so ids, labels and + * light/dark tone are identical on both apps and no id is ever unknown here. + */ +object LookPresets { + + /** + * Placeholder fallback for the two default lookups, which would otherwise + * recurse through [byId]'s default argument. A generated palette always + * contains both Obsidian ids and LookPalettesTest asserts it, so reaching + * this would mean the generated file is broken; the caller still gets a + * usable value instead of a throw. + */ + private val MISSING: LookPalette = ALL_LOOK_PALETTES.first() + + /** Dark presets, in the order the desktop lists them. */ + val dark: List = ALL_LOOK_PALETTES.filter { it.dark } + + /** Light presets, in the order the desktop lists them. */ + val light: List = ALL_LOOK_PALETTES.filter { !it.dark } + + /** Every preset, in the order the desktop lists them. */ + val all: List = ALL_LOOK_PALETTES + + /** + * The desktop's default for a tone, and this app's: Obsidian in both light + * and dark, matching `defaultPresetForTone` on the desktop. + * + * [byId] defaults to the dark default, so the light default passes its own + * fallback rather than relying on that. + */ + fun defaultForDark(): LookPalette = byId(PRESET_OBSIDIAN, MISSING) + + fun defaultForLight(): LookPalette = byId(PRESET_OBSIDIAN_LIGHT, MISSING) + + fun defaultFor(dark: Boolean): LookPalette = if (dark) defaultForDark() else defaultForLight() + + /** + * The palette for [id] as long as it suits [dark]; otherwise the default for + * that tone. This is the one place that resolves a stored id, and it enforces + * the invariant a light preset is never drawn in dark mode. Settings can hold + * a mismatched id (hand-edited preferences, a preset removed upstream), so + * every read goes through here. + */ + fun forTone(dark: Boolean, id: String?): LookPalette { + val preset = byId(id, defaultFor(dark)) + return if (preset.dark == dark) preset else defaultFor(dark) + } + + /** + * The palette for [id], or [fallback] when the id is missing or unknown. + * Settings stored by an older build can name a preset that no longer exists, + * so callers resolve through here rather than indexing directly. + */ + fun byId(id: String?, fallback: LookPalette = defaultForDark()): LookPalette = + ALL_LOOK_PALETTES.firstOrNull { it.id == id } ?: fallback + + /** True when [id] names a preset this build knows. */ + fun isKnown(id: String?): Boolean = id != null && ALL_LOOK_PALETTES.any { it.id == id } + + const val PRESET_OBSIDIAN = "obsidian" + const val PRESET_OBSIDIAN_LIGHT = "obsidian-light" +} diff --git a/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt new file mode 100644 index 00000000..593a5ac3 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt @@ -0,0 +1,529 @@ +package com.parallelcode.phone +import android.Manifest +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.snapshotFlow +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableFloatStateOf +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Which agent changes notify, from Settings (see [AgentWatchService]). */ +data class NotificationPrefs( + val enabled: Boolean, + val needsInput: Boolean, + val errors: Boolean, + val finished: Boolean, +) + +class PhoneViewModel(application: Application) : AndroidViewModel(application) { + private val phoneApp = application as PhoneApplication + val client = phoneApp.client + val settingsStore = phoneApp.settings + val promptHistory = phoneApp.promptHistory + + /** An agent to open, from a tapped notification. */ + val openAgentRequest = MutableStateFlow(null) + + private val _notifications = MutableStateFlow(readNotificationPrefs()) + val notifications: StateFlow = _notifications.asStateFlow() + + private fun readNotificationPrefs() = NotificationPrefs( + enabled = settingsStore.notificationsEnabled, + needsInput = settingsStore.notifyNeedsInput, + errors = settingsStore.notifyErrors, + finished = settingsStore.notifyFinished, + ) + + fun setNotifications(prefs: NotificationPrefs) { + settingsStore.notificationsEnabled = prefs.enabled + settingsStore.notifyNeedsInput = prefs.needsInput + settingsStore.notifyErrors = prefs.errors + settingsStore.notifyFinished = prefs.finished + _notifications.value = prefs + AgentWatchService.sync(getApplication()) + } + private val _keepScreenOn = MutableStateFlow(settingsStore.keepScreenOn) + val keepScreenOn: StateFlow = _keepScreenOn.asStateFlow() + + private val _keepScreenOnOnlyWhenActive = MutableStateFlow(settingsStore.keepScreenOnOnlyWhenActive) + val keepScreenOnOnlyWhenActive: StateFlow = _keepScreenOnOnlyWhenActive.asStateFlow() + + private val _themeMode = MutableStateFlow(settingsStore.themeMode) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _darkThemePreset = MutableStateFlow(settingsStore.darkThemePreset) + val darkThemePreset: StateFlow = _darkThemePreset.asStateFlow() + + private val _lightThemePreset = MutableStateFlow(settingsStore.lightThemePreset) + val lightThemePreset: StateFlow = _lightThemePreset.asStateFlow() + + private val _showMinimizedTasks = MutableStateFlow(settingsStore.showMinimizedTasks) + val showMinimizedTasks: StateFlow = _showMinimizedTasks.asStateFlow() + + private val _quickReplies = MutableStateFlow(settingsStore.quickReplies) + val quickReplies: StateFlow> = _quickReplies.asStateFlow() + + fun setQuickReplies(value: List) { + settingsStore.quickReplies = value + _quickReplies.value = settingsStore.quickReplies + } + + private val _sendQuickReplies = MutableStateFlow(settingsStore.sendQuickReplies) + val sendQuickReplies: StateFlow = _sendQuickReplies.asStateFlow() + + fun setSendQuickReplies(value: Boolean) { + settingsStore.sendQuickReplies = value + _sendQuickReplies.value = value + } + + private val _fitTerminalToPhone = MutableStateFlow(settingsStore.fitTerminalToPhone) + val fitTerminalToPhone: StateFlow = _fitTerminalToPhone.asStateFlow() + + fun setFitTerminalToPhone(value: Boolean) { + settingsStore.fitTerminalToPhone = value + _fitTerminalToPhone.value = value + } + + private val _alwaysFollowOutput = MutableStateFlow(settingsStore.alwaysFollowOutput) + val alwaysFollowOutput: StateFlow = _alwaysFollowOutput.asStateFlow() + + fun setAlwaysFollowOutput(value: Boolean) { + settingsStore.alwaysFollowOutput = value + _alwaysFollowOutput.value = value + } + + fun setKeepScreenOn(value: Boolean) { + settingsStore.keepScreenOn = value + _keepScreenOn.value = value + } + + fun setKeepScreenOnOnlyWhenActive(value: Boolean) { + settingsStore.keepScreenOnOnlyWhenActive = value + _keepScreenOnOnlyWhenActive.value = value + } + + fun setThemeMode(value: String) { + settingsStore.themeMode = value + _themeMode.value = value + } + + fun setDarkThemePreset(value: String) { + settingsStore.darkThemePreset = value + _darkThemePreset.value = settingsStore.darkThemePreset + } + + fun setLightThemePreset(value: String) { + settingsStore.lightThemePreset = value + _lightThemePreset.value = settingsStore.lightThemePreset + } + + fun setShowMinimizedTasks(value: Boolean) { + settingsStore.showMinimizedTasks = value + _showMinimizedTasks.value = value + } + + private val _widgetTransparency = MutableStateFlow(settingsStore.widgetTransparency) + val widgetTransparency: StateFlow = _widgetTransparency.asStateFlow() + + fun setWidgetTransparency(value: Int) { + settingsStore.widgetTransparency = value + _widgetTransparency.value = settingsStore.widgetTransparency + // The widget draws on its own schedule, so push the new card to it right away. + AgentWidget.refresh(getApplication()) + } + + private val _widgetPalette = MutableStateFlow(settingsStore.widgetPalette) + val widgetPalette: StateFlow = _widgetPalette.asStateFlow() + + fun setWidgetPalette(value: String) { + settingsStore.widgetPalette = value + _widgetPalette.value = settingsStore.widgetPalette + AgentWidget.refresh(getApplication()) + } +} + +class MainActivity : ComponentActivity() { + private val model: PhoneViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + openAgentFrom(intent) + enableEdgeToEdge() + setContent { + val keepScreenOn by model.keepScreenOn.collectAsState() + val keepScreenOnOnlyActive by model.keepScreenOnOnlyWhenActive.collectAsState() + val agents by model.client.agents.collectAsState() + val themeMode by model.themeMode.collectAsState() + val darkThemePreset by model.darkThemePreset.collectAsState() + val lightThemePreset by model.lightThemePreset.collectAsState() + + val hasActiveAgent = agents.any { it.running && (it.attention == "active" || it.attention == "shell_busy") } + val shouldKeepAwake = keepScreenOn && (!keepScreenOnOnlyActive || hasActiveAgent) + + DisposableEffect(shouldKeepAwake) { + if (shouldKeepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + val darkTheme = when (themeMode) { + SettingsStore.THEME_DARK -> true + SettingsStore.THEME_LIGHT -> false + else -> isSystemInDarkTheme() + } + + val palette = resolveLookPalette( + darkPresetId = darkThemePreset, + lightPresetId = lightThemePreset, + darkTheme = darkTheme, + ) + + // enableEdgeToEdge() in onCreate follows the system tone, but a look can + // differ from it (Always light on a dark phone), so the system bar icon + // tint is re-applied for the palette actually on screen. + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !palette.dark + isAppearanceLightNavigationBars = !palette.dark + } + } + } + + ParallelCodeTheme(palette = palette) { + PhoneApp(model) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + openAgentFrom(intent) + } + + private fun openAgentFrom(intent: Intent?) { + intent?.getStringExtra(EXTRA_AGENT_ID)?.let { model.openAgentRequest.value = it } + } + + // The socket stays open while the app is visible, like the phone web UI's tab, and in the + // background only while agent notifications are on. + override fun onStart() { + super.onStart() + (application as PhoneApplication).inForeground = true + model.client.start(HOLDER) + model.client.resumeViewSize() + AgentWatchService.sync(this) + } + + override fun onStop() { + model.client.pauseViewSize() + model.client.stop(HOLDER) + (application as PhoneApplication).inForeground = false + super.onStop() + } + + companion object { + const val EXTRA_AGENT_ID = "agentId" + private const val HOLDER = "app" + } +} + +private sealed interface Screen { + data object Agents : Screen + data object Pair : Screen + data object NewTask : Screen + data object Settings : Screen + data class Agent(val agentId: String) : Screen +} + +@Composable +private fun PhoneApp(model: PhoneViewModel) { + val state by model.client.state.collectAsState() + val agents by model.client.agents.collectAsState() + var screenKey by rememberSaveable { mutableStateOf("agents") } + val screen = when { + screenKey == "pair" -> Screen.Pair + screenKey == "new-task" -> Screen.NewTask + screenKey == "settings" -> Screen.Settings + screenKey.startsWith("agent:") -> Screen.Agent(screenKey.removePrefix("agent:")) + else -> Screen.Agents + } + val link = state.link + val openAgentRequest by model.openAgentRequest.collectAsState() + val haptic = LocalHapticFeedback.current + LaunchedEffect(openAgentRequest) { + val agentId = openAgentRequest ?: return@LaunchedEffect + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + screenKey = "agent:$agentId" + model.openAgentRequest.value = null + } + + val computers by model.client.computers.collectAsState() + if (link == null) { + ConnectScreen( + expired = state.linkExpired, + onLink = { model.client.link(it) }, + saved = computers, + onSelectSaved = { model.client.switchTo(it) }, + ) + return + } + if (screenKey == "add-computer") { + BackHandler { screenKey = "settings" } + ConnectScreen( + expired = false, + onLink = { + model.client.link(it) + screenKey = "agents" + }, + onCancel = { screenKey = "settings" }, + ) + return + } + // Predictive back: the screen shrinks and slides with the swipe, and only leaves on release. + var backProgress by remember { mutableFloatStateOf(0f) } + if (screen != Screen.Agents) { + PredictiveBackHandler { events -> + try { + events.collect { backProgress = it.progress } + screenKey = "agents" + } finally { + backProgress = 0f + } + } + } + AnimatedContent( + targetState = screen, + modifier = Modifier.graphicsLayer { + val p = backProgress + scaleX = 1f - 0.08f * p + scaleY = 1f - 0.08f * p + translationX = p * 48.dp.toPx() + alpha = 1f - 0.25f * p + }, + transitionSpec = { + if (initialState == Screen.Agents) { + (slideInHorizontally(animationSpec = tween(280)) { width -> (width * 0.15f).toInt() } + fadeIn(tween(250))) + .togetherWith(slideOutHorizontally(animationSpec = tween(240)) { width -> -(width * 0.15f).toInt() } + fadeOut(tween(200))) + } else if (targetState == Screen.Agents) { + (slideInHorizontally(animationSpec = tween(280)) { width -> -(width * 0.15f).toInt() } + fadeIn(tween(250))) + .togetherWith(slideOutHorizontally(animationSpec = tween(240)) { width -> (width * 0.15f).toInt() } + fadeOut(tween(200))) + } else { + fadeIn(tween(200)).togetherWith(fadeOut(tween(200))) + } + }, + // Swiping between tasks changes which agent is open without leaving the task pager. + contentKey = { if (it is Screen.Agent) "agent" else it }, + label = "screenTransition", + ) { currentScreen -> + when (currentScreen) { + Screen.Agents -> { + val showMinimizedTasks by model.showMinimizedTasks.collectAsState() + AgentsScreen( + client = model.client, + host = link.baseUrl, + state = state, + agents = agents, + showMinimizedTasks = showMinimizedTasks, + onOpen = { screenKey = "agent:${it.agentId}" }, + onPair = { screenKey = "pair" }, + onNewTask = { screenKey = "new-task" }, + onSettings = { screenKey = "settings" }, + computers = computers, + onSwitchComputer = { model.client.switchTo(it) }, + ) + } + Screen.Settings -> { + val keepScreenOn by model.keepScreenOn.collectAsState() + val keepScreenOnOnlyActive by model.keepScreenOnOnlyWhenActive.collectAsState() + val themeMode by model.themeMode.collectAsState() + val darkThemePreset by model.darkThemePreset.collectAsState() + val lightThemePreset by model.lightThemePreset.collectAsState() + val showMinimizedTasks by model.showMinimizedTasks.collectAsState() + val latencyMs by model.client.latencyMs.collectAsState() + val alwaysFollowOutput by model.alwaysFollowOutput.collectAsState() + val notifications by model.notifications.collectAsState() + val quickReplies by model.quickReplies.collectAsState() + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + model.setNotifications(notifications.copy(enabled = granted)) + } + val context = LocalContext.current + SettingsScreen( + keepScreenOn = keepScreenOn, + onKeepScreenOnChange = model::setKeepScreenOn, + keepScreenOnOnlyActive = keepScreenOnOnlyActive, + onKeepScreenOnOnlyActiveChange = model::setKeepScreenOnOnlyWhenActive, + themeMode = themeMode, + onThemeModeChange = model::setThemeMode, + darkThemePreset = darkThemePreset, + onDarkThemePresetChange = model::setDarkThemePreset, + lightThemePreset = lightThemePreset, + onLightThemePresetChange = model::setLightThemePreset, + showMinimizedTasks = showMinimizedTasks, + onShowMinimizedTasksChange = model::setShowMinimizedTasks, + alwaysFollowOutput = alwaysFollowOutput, + onAlwaysFollowOutputChange = model::setAlwaysFollowOutput, + fitTerminalToPhone = model.fitTerminalToPhone.collectAsState().value, + onFitTerminalToPhoneChange = model::setFitTerminalToPhone, + widgetTransparency = model.widgetTransparency.collectAsState().value, + onWidgetTransparencyChange = model::setWidgetTransparency, + widgetPalette = model.widgetPalette.collectAsState().value, + onWidgetPaletteChange = model::setWidgetPalette, + quickReplies = quickReplies, + onQuickRepliesChange = model::setQuickReplies, + sendQuickReplies = model.sendQuickReplies.collectAsState().value, + onSendQuickRepliesChange = model::setSendQuickReplies, + notifications = notifications, + onNotificationsChange = { prefs -> + val needsPermission = prefs.enabled && !notifications.enabled && + Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + if (needsPermission) permission.launch(Manifest.permission.POST_NOTIFICATIONS) + else model.setNotifications(prefs) + }, + latencyMs = latencyMs, + state = state, + computers = computers, + onSwitchComputer = { + model.client.switchTo(it) + screenKey = "agents" + }, + onRenameComputer = { url, alias -> model.client.rename(url, alias) }, + onForgetComputer = { model.client.forget(it) }, + onAddComputer = { screenKey = "add-computer" }, + onPair = { screenKey = "pair" }, + onForget = { + model.client.forget() + AgentWatchService.sync(context) + screenKey = "agents" + }, + onBack = { screenKey = "agents" }, + ) + } + Screen.Pair -> PairScreen( + pair = model.client::pair, + onDone = { screenKey = "agents" }, + ) + Screen.NewTask -> NewTaskScreen( + client = model.client, + onDone = { screenKey = "agents" }, + onNeedsPairing = { screenKey = "pair" }, + ) + is Screen.Agent -> { + val alwaysFollowOutput by model.alwaysFollowOutput.collectAsState() + val quickReplies by model.quickReplies.collectAsState() + val sendQuickReplies by model.sendQuickReplies.collectAsState() + val fitTerminalToPhone by model.fitTerminalToPhone.collectAsState() + // The tasks in the order the list shows them: swipe sideways to move between them. + val pages = remember(agents) { agents.filter { !it.collapsed } + agents.filter { it.collapsed } } + val openIndex = pages.indexOfFirst { it.agentId == currentScreen.agentId } + + @Composable + fun Task(agentId: String, active: Boolean, pageLabel: String?) { + val agent = agents.firstOrNull { it.agentId == agentId } + if (agent?.isChat == true) { + ChatScreen( + agent = agent, + agentId = agentId, + state = state, + client = model.client, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, + promptHistory = model.promptHistory, + pageLabel = pageLabel, + onBack = { screenKey = "agents" }, + onPair = { screenKey = "pair" }, + ) + } else AgentScreen( + agent = agent, + agentId = agentId, + state = state, + client = model.client, + alwaysFollowOutput = alwaysFollowOutput, + fitTerminalToPhone = fitTerminalToPhone && active, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, + promptHistory = model.promptHistory, + pageLabel = pageLabel, + onBack = { screenKey = "agents" }, + onPair = { screenKey = "pair" }, + ) + } + + if (openIndex < 0 || pages.size < 2) { + // A task that left the list (or the only one) has nothing to swipe to. + Task(currentScreen.agentId, active = true, pageLabel = null) + } else { + val pager = rememberPagerState(initialPage = openIndex) { pages.size } + LaunchedEffect(pager, pages) { + snapshotFlow { pager.settledPage }.collect { page -> + pages.getOrNull(page)?.let { screenKey = "agent:${it.agentId}" } + } + } + HorizontalPager( + state = pager, + key = { pages[it].agentId }, + pageSpacing = 8.dp, + ) { page -> + // Only the settled page may resize the desktop terminal; a neighbour + // composed mid-swipe just shows its output. + Task(pages[page].agentId, active = page == pager.settledPage, pageLabel = "${page + 1} of ${pages.size}") + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/MergePane.kt b/android/app/src/main/java/com/parallelcode/phone/MergePane.kt new file mode 100644 index 00000000..037ab2ac --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/MergePane.kt @@ -0,0 +1,193 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** The desktop's readiness verdicts, as its merge panel words them. */ +private fun overallTitle(overall: String): String = when (overall) { + "ready" -> "Ready to merge" + "attention" -> "Needs attention" + "blocked" -> "Not ready to merge" + else -> "Checking" +} + +@Composable +private fun statusColor(status: String) = when (status) { + "pass" -> AppTheme.extra.success + "blocked" -> MaterialTheme.colorScheme.error + "warning", "checking" -> AppTheme.extra.warningText + else -> AppTheme.extra.textMuted +} + +private fun statusSymbol(status: String): String = when (status) { + "pass" -> "✓" + "blocked" -> "×" + "warning" -> "!" + "checking" -> "…" + else -> "—" +} + +/** + * Merge a task from the phone. + * + * The readiness checks are the desktop's own, read-only and advisory — opening this + * dialog never runs verification or tests. A blocked verdict disables the confirm + * button; a warning does not, so someone away from their desk can still merge with + * eyes open. Mirrors the phone web UI's dialog in src/remote/TaskActionsDialog.tsx. + */ +@Composable +internal fun MergeTaskDialog( + taskId: String, + client: RemoteClient, + onDismiss: () -> Unit, + onMerged: () -> Unit, +) { + val scope = rememberCoroutineScope() + var readiness by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var squash by remember { mutableStateOf(false) } + var cleanup by remember { mutableStateOf(false) } + + var loadAttempt by remember { mutableIntStateOf(0) } + + LaunchedEffect(taskId, loadAttempt) { + loading = true + error = null + try { + readiness = client.fetchMergeReadiness(taskId) + } catch (e: ApiException) { + error = + if (e.status == 403 || e.status == 404) { + "Update Parallel Code on your computer to merge here." + } else { + e.message + } + } finally { + loading = false + } + } + + fun submit() { + busy = true + error = null + scope.launch { + try { + client.mergeTask(taskId, squash, cleanup) + onMerged() + } catch (e: ApiException) { + error = e.message + } finally { + busy = false + } + } + } + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { + Text(readiness?.let { "Merge into ${it.baseBranch}" } ?: "Merge task") + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (loading && readiness == null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + val current = readiness + if (current != null) { + Text( + overallTitle(current.overall), + color = statusColor(if (current.overall == "ready") "pass" else "warning"), + fontWeight = FontWeight.SemiBold, + ) + current.checks.forEach { check -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + statusSymbol(check.status), + color = statusColor(check.status), + fontWeight = FontWeight.SemiBold, + ) + Column { + Text(check.label, fontWeight = FontWeight.SemiBold) + Text( + check.detail, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = squash, onCheckedChange = { squash = it }) + Text("Squash into one commit") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = cleanup, onCheckedChange = { cleanup = it }) + Text("Close the task and remove its worktree afterwards") + } + } + error?.let { + Box( + Modifier + .fillMaxWidth() + .background(AppTheme.extra.warningBannerBg) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text(it, color = AppTheme.extra.warningText) + } + } + } + }, + confirmButton = { + TextButton( + onClick = ::submit, + // A blocker must be resolved on the computer first; a warning must not. + enabled = !busy && readiness?.canMerge == true, + ) { + if (busy) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Merge", fontWeight = FontWeight.SemiBold) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } + }, + ) +} diff --git a/android/app/src/main/java/com/parallelcode/phone/MergeReadiness.kt b/android/app/src/main/java/com/parallelcode/phone/MergeReadiness.kt new file mode 100644 index 00000000..bfaa2ddc --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/MergeReadiness.kt @@ -0,0 +1,24 @@ +package com.parallelcode.phone + +import org.json.JSONObject + +/** Parsing for the desktop's merge-readiness reply (GET /api/mobile/tasks/:taskId/readiness). */ +internal fun parseMergeReadiness(json: JSONObject): MergeReadiness { + val readiness = json.getJSONObject("readiness") + val checks = readiness.getJSONArray("checks") + return MergeReadiness( + overall = readiness.optString("overall", "checking"), + // Default to refusing: an unreadable verdict must not enable a git write. + canMerge = json.optBoolean("canMerge", false), + baseBranch = json.optString("baseBranch", "main"), + branchName = json.optString("branchName", ""), + checks = List(checks.length()) { i -> + val check = checks.getJSONObject(i) + ReadinessCheck( + label = check.optString("label"), + status = check.optString("status", "neutral"), + detail = check.optString("detail"), + ) + }, + ) +} diff --git a/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt b/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt new file mode 100644 index 00000000..ccc1360a --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt @@ -0,0 +1,39 @@ +package com.parallelcode.phone + +import android.app.Application +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Owns the connection so the visible app and the background notification service share one socket + * (see [RemoteClient.start]). + */ +class PhoneApplication : Application() { + lateinit var client: RemoteClient + private set + lateinit var settings: SettingsStore + private set + lateinit var promptHistory: PromptHistoryStore + private set + + /** True while the app is on screen; agent notifications stay quiet then. */ + @Volatile + var inForeground = false + + override fun onCreate() { + super.onCreate() + client = RemoteClient(CredentialStore(getSharedPreferences("desktop", Context.MODE_PRIVATE))) + settings = SettingsStore(getSharedPreferences(SettingsStore.PREFS_NAME, Context.MODE_PRIVATE)) + promptHistory = PromptHistoryStore(getSharedPreferences(PromptHistoryStore.PREFS_NAME, Context.MODE_PRIVATE)) + // Keep the home-screen widget current whenever the connection is open. + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { + combine(client.agents, client.usage, client.state) { agents, usage, state -> + widgetSummary(agents, usage, state.status == ConnectionStatus.CONNECTED) + }.collect { AgentWidget.publish(this@PhoneApplication, it) } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/PromptHistoryStore.kt b/android/app/src/main/java/com/parallelcode/phone/PromptHistoryStore.kt new file mode 100644 index 00000000..1bfd5fbe --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/PromptHistoryStore.kt @@ -0,0 +1,54 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject + +/** + * Messages this phone has sent, per agent, newest first. Powers prompt recall in the + * composers: tapping a past message puts it back in the draft, ready to edit or send. + * Bounded so it cannot grow without limit (see [MAX_PER_AGENT] and [MAX_AGENTS]). + */ +class PromptHistoryStore(private val prefs: SharedPreferences) { + + /** Past messages sent to [agentId], newest first. */ + fun history(agentId: String): List = read()[agentId].orEmpty() + + /** Remember a sent message; blanks are ignored and repeats move back to the front. */ + fun record(agentId: String, message: String) { + val text = message.trim().take(MAX_MESSAGE_LENGTH) + if (text.isEmpty()) return + val all = read().toMutableMap() + all[agentId] = ((listOf(text) + all[agentId].orEmpty()).distinct()).take(MAX_PER_AGENT) + while (all.size > MAX_AGENTS) all.remove(all.keys.first()) + write(all) + } + + private fun read(): Map> { + val root = runCatching { JSONObject(prefs.getString(KEY_HISTORY, "{}") ?: "{}") }.getOrElse { JSONObject() } + return LinkedHashMap>().also { out -> + root.keys().forEach { id -> + val list = root.optJSONArray(id) ?: return@forEach + out[id] = List(list.length()) { list.optString(it) }.filter { it.isNotEmpty() } + } + } + } + + private fun write(all: Map>) { + val root = JSONObject() + all.forEach { (id, messages) -> root.put(id, JSONArray(messages)) } + prefs.edit { putString(KEY_HISTORY, root.toString()) } + } + + companion object { + const val PREFS_NAME = "promptHistory" + const val KEY_HISTORY = "history" + /** Past messages kept per agent. */ + const val MAX_PER_AGENT = 20 + /** Agents kept at all; the least recently recorded falls off. */ + const val MAX_AGENTS = 50 + /** Very long pastes are trimmed so one giant message cannot crowd out the rest. */ + const val MAX_MESSAGE_LENGTH = 2000 + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Protocol.kt b/android/app/src/main/java/com/parallelcode/phone/Protocol.kt new file mode 100644 index 00000000..d78ea1a7 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Protocol.kt @@ -0,0 +1,99 @@ +package com.parallelcode.phone + +import org.json.JSONObject + +// Mirrors the server messages in electron/remote/protocol.ts that this app uses. + +data class RemoteAgent( + val agentId: String, + val taskId: String, + val taskName: String, + val running: Boolean, + val exitCode: Int?, + val lastLine: String, + val projectName: String?, + val agentName: String?, + /** Renderer-derived status such as `needs_input`, `active` or `idle`. */ + val attention: String, + /** The desktop's built-in chat has no terminal to stream. */ + val isChat: Boolean, + val collapsed: Boolean = false, +) + +sealed interface ServerMessage { + data class Agents(val list: List) : ServerMessage + data class Output(val agentId: String, val data: ByteArray) : ServerMessage + data class Scrollback(val agentId: String, val data: ByteArray, val cols: Int, val rows: Int) : + ServerMessage + data class Status(val agentId: String, val running: Boolean, val exitCode: Int?) : ServerMessage + data class InputResult(val requestId: String, val ok: Boolean, val error: String?) : + ServerMessage + data class Chat(val agentId: String, val state: ChatState) : ServerMessage +} + +private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key) && !isNull(key)) optString(key) else null + +private fun JSONObject.optIntOrNull(key: String): Int? = + if (has(key) && !isNull(key)) optInt(key) else null + +private fun decodeBase64(data: String): ByteArray = java.util.Base64.getDecoder().decode(data) + +/** Returns null for messages this app ignores and for malformed ones. */ +fun parseServerMessage(raw: String): ServerMessage? = try { + val msg = JSONObject(raw) + when (msg.getString("type")) { + "agents" -> { + val list = msg.getJSONArray("list") + ServerMessage.Agents(List(list.length()) { i -> parseAgent(list.getJSONObject(i)) }) + } + "output" -> ServerMessage.Output(msg.getString("agentId"), decodeBase64(msg.getString("data"))) + "scrollback" -> ServerMessage.Scrollback( + msg.getString("agentId"), + decodeBase64(msg.getString("data")), + msg.getInt("cols"), + msg.optInt("rows", 24), + ) + "status" -> ServerMessage.Status( + msg.getString("agentId"), + msg.getString("status") == "running", + msg.optIntOrNull("exitCode"), + ) + "chat-state" -> ServerMessage.Chat(msg.getString("agentId"), parseChatState(msg.getJSONObject("state"))) + "input-result" -> ServerMessage.InputResult( + msg.getString("requestId"), + msg.getBoolean("ok"), + msg.optStringOrNull("error"), + ) + else -> null + } +} catch (_: Exception) { + null +} + +private fun parseAgent(a: JSONObject) = RemoteAgent( + agentId = a.getString("agentId"), + taskId = a.getString("taskId"), + taskName = a.getString("taskName"), + running = a.getString("status") == "running", + exitCode = a.optIntOrNull("exitCode"), + lastLine = a.optString("lastLine"), + projectName = a.optStringOrNull("projectName"), + agentName = a.optStringOrNull("agentName"), + attention = a.optString("attention", "idle"), + isChat = a.optString("kind") == "chat", + collapsed = a.optBoolean("collapsed", false), +) + +/** + * Prepares a reply the way the phone web UI does (src/remote/terminalText.ts): control characters + * must not escape the pasted region or submit midway. + */ +fun messageForTerminal(text: String, bracketedPaste: Boolean): String { + val clean = text + .replace(Regex("\r\n?"), "\n") + .replace(Regex("[\\x00-\\x08\\x0b-\\x1f\\x7f]"), "") + .trim() + if (clean.isEmpty()) return "" + return if (bracketedPaste) "\u001b[200~$clean\u001b[201~" else clean.replace('\n', ' ') +} diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt new file mode 100644 index 00000000..eb50937b --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -0,0 +1,657 @@ +package com.parallelcode.phone + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +enum class ConnectionStatus { CONNECTING, CONNECTED, DISCONNECTED } + +/** What the UI needs to know about the link to the desktop. */ +data class ConnectionState( + /** The linked desktop; null until a QR code is scanned. */ + val link: ConnectionLink? = null, + val status: ConnectionStatus = ConnectionStatus.DISCONNECTED, + /** True once the socket authenticated with the paired token, which may type. */ + val canControl: Boolean = false, + /** Set when the desktop rejected the QR-code token; only a fresh scan recovers. */ + val linkExpired: Boolean = false, +) + +interface TerminalListener { + fun onScrollback(data: ByteArray, cols: Int, rows: Int) + fun onOutput(data: ByteArray) +} + +/** A REST call the desktop refused or could not answer; `status` is 0 when it was unreachable. */ +/** [json] is the error reply's body, for routes that explain a refusal (e.g. close warnings). */ +class ApiException(message: String, val status: Int = 0, val json: JSONObject? = null) : IOException(message) + +data class MobileProject(val id: String, val name: String, val agentName: String?) + +/** One row of the desktop's merge-readiness panel, as that panel labels it. */ +data class ReadinessCheck(val label: String, val status: String, val detail: String) + +/** + * The desktop's merge readiness for a task. [canMerge] is false only for a merge-safety + * blocker, never for a warning, so a warning still leaves merging possible. + */ +data class MergeReadiness( + val overall: String, + val canMerge: Boolean, + val baseBranch: String, + val branchName: String, + val checks: List, +) + +/** + * Client for the desktop's Remote Access server (electron/remote/server.ts). Mirrors the phone web + * UI in src/remote/ws.ts: authenticate with the first WebSocket message, prefer the paired token, + * and fall back to view-only when the desktop revokes it. All state changes on the main thread. + */ +class RemoteClient(private val credentials: CredentialStore) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private val _state = MutableStateFlow(ConnectionState(link = credentials.link)) + val state: StateFlow = _state.asStateFlow() + private val _agents = MutableStateFlow>(emptyList()) + val agents: StateFlow> = _agents.asStateFlow() + private val _computers = MutableStateFlow(credentials.computers) + + /** Every desktop this phone has linked to; [ConnectionState.link] is the one in use. */ + val computers: StateFlow> = _computers.asStateFlow() + private val _latencyMs = MutableStateFlow(null) + val latencyMs: StateFlow = _latencyMs.asStateFlow() + + private var socket: WebSocket? = null + // OkHttp queues sends before the socket opens, which would put them ahead of the auth message. + private var socketOpen = false + private var authKind = TokenKind.MOBILE + private var started = false + private var reconnectJob: Job? = null + private var handshakeJob: Job? = null + private val terminalBuffers = mutableMapOf() + private val subscribedAgents = mutableSetOf() + private val chats = mutableMapOf>() + private val chatWatchers = mutableMapOf() + /** The agent whose PTY takes this phone's terminal size, and that size (see [setViewSize]). */ + private var viewSize: Triple? = null + private var viewSizeSent = false + private val terminalListeners = mutableMapOf>() + private val pending = mutableMapOf>() + private var nextRequestId = 0 + + private enum class TokenKind { MOBILE, PAIRED } + + /** Keep a socket open while the app is in the foreground. */ + private val holders = mutableSetOf() + + /** + * Keep the connection open for [holder] (the visible app, the background notification service); + * it closes once no holder remains. + */ + fun start(holder: String) { + holders.add(holder) + started = true + connect() + } + + fun stop(holder: String) { + holders.remove(holder) + if (holders.isNotEmpty()) return + started = false + closeSocket() + } + + fun link(link: ConnectionLink) { + credentials.saveLink(link) + useActiveComputer() + } + + /** Switch to another saved computer. */ + fun switchTo(baseUrl: String) { + if (baseUrl == credentials.link?.baseUrl) return + credentials.select(baseUrl) + useActiveComputer() + } + + /** Name a saved computer; a blank name clears the one it had. */ + fun rename(baseUrl: String, alias: String?) { + credentials.rename(baseUrl, alias) + _computers.value = credentials.computers + } + + /** Forget a saved computer; forgetting the one in use leaves the phone unlinked. */ + fun forget(baseUrl: String? = credentials.link?.baseUrl) { + if (baseUrl == null) return + val inUse = baseUrl == credentials.link?.baseUrl + credentials.remove(baseUrl) + _computers.value = credentials.computers + if (!inUse) return + closeSocket() + resetSession() + _state.value = ConnectionState() + } + + /** Drop everything from the previous computer and connect to the one now selected. */ + private fun useActiveComputer() { + closeSocket() + resetSession() + _computers.value = credentials.computers + _state.value = ConnectionState(link = credentials.link) + reconnect() + } + + private fun resetSession() { + _agents.value = emptyList() + _latencyMs.value = null + _usage.value = emptyList() + terminalBuffers.clear() + subscribedAgents.clear() + terminalListeners.clear() + chats.values.forEach { it.value = null } + viewSize = null + viewSizeSent = false + } + + fun reconnect() { + closeSocket() + if (started) connect() + } + + /** Trade the desktop's six-digit PIN for a paired token, then reconnect with it. */ + suspend fun pair(pin: String, remember: Boolean) { + val reply = api( + "POST", + "/api/pair/verify", + JSONObject().put("pin", pin).put("remember", remember), + token = credentials.pairedToken ?: credentials.link?.token, + ) + val token = reply.optString("token").takeIf { it.isNotEmpty() } + ?: throw ApiException("Your computer sent an unexpected reply.") + credentials.savePairedToken(token) + _computers.value = credentials.computers + reconnect() + } + + /** Projects a paired phone may start tasks in. */ + suspend fun fetchProjects(): List { + val list = JSONArray(apiRaw("GET", "/api/mobile/projects", null, pairedTokenOrThrow())) + return List(list.length()) { i -> + val p = list.getJSONObject(i) + MobileProject(p.getString("id"), p.getString("name"), p.optString("agentName").ifEmpty { null }) + } + } + + /** Start a top-level task on the desktop; returns its task id. */ + suspend fun createTask(projectId: String, name: String, prompt: String): String { + val body = JSONObject().put("projectId", projectId).put("name", name).put("prompt", prompt) + return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow()).getString("taskId") + } + + /** The task's notes panel; readable with the view-only token. */ + suspend fun fetchNotes(taskId: String): String = + api("GET", notesPath(taskId), null, credentials.pairedToken ?: credentials.link?.token) + .optString("notes") + + suspend fun saveNotes(taskId: String, notes: String) { + api("PUT", notesPath(taskId), JSONObject().put("notes", notes), pairedTokenOrThrow()) + } + + /** The task's changes against its base branch; readable with the view-only token. */ + suspend fun fetchDiff(taskId: String): TaskDiff { + val json = api("GET", "/api/mobile/tasks/${encodePath(taskId)}/diff", null, credentials.pairedToken ?: credentials.link?.token) + return TaskDiff.from(json) + } + + /** The desktop status bar's subscription usage; readable with the view-only token. */ + suspend fun fetchUsage(): List = + parseUsage(api("GET", "/api/mobile/usage", null, credentials.pairedToken ?: credentials.link?.token)) + .also { _usage.value = it } + + /** + * The desktop's merge-readiness checks for a task, built by the same + * `buildMergeReadiness` the desktop dialog uses. Read-only, so the view-only + * token may read it. + */ + suspend fun fetchMergeReadiness(taskId: String): MergeReadiness = + parseMergeReadiness( + api( + "GET", + "/api/mobile/tasks/${encodePath(taskId)}/readiness", + null, + credentials.pairedToken ?: credentials.link?.token, + ), + ) + + /** Merge a task into its base branch. Runs real git, so it needs the paired token. */ + suspend fun mergeTask(taskId: String, squash: Boolean, cleanup: Boolean) { + api( + "POST", + "/api/mobile/tasks/${encodePath(taskId)}/merge", + JSONObject().put("squash", squash).put("cleanup", cleanup), + pairedTokenOrThrow(), + ) + } + + private val _usage = MutableStateFlow>(emptyList()) + + /** The last usage snapshot fetched, for the widget. */ + val usage: StateFlow> = _usage.asStateFlow() + + /** + * Close a task on the desktop: stops its agents and removes its worktree. Unless [force], the + * desktop refuses when work would be lost and this returns its warnings; empty means closed. + */ + suspend fun closeTask(taskId: String, force: Boolean): List = try { + api("POST", "/api/mobile/tasks/${encodePath(taskId)}/close", JSONObject().put("force", force), pairedTokenOrThrow()) + emptyList() + } catch (e: ApiException) { + val warnings = e.json?.optJSONArray("warnings") + if (e.status != 409 || warnings == null || warnings.length() == 0) throw e + List(warnings.length()) { warnings.optString(it) } + } + + private fun notesPath(taskId: String) = "/api/mobile/notes/" + encodePath(taskId) + + private fun encodePath(segment: String) = URLEncoder.encode(segment, "UTF-8").replace("+", "%20") + + private fun pairedTokenOrThrow(): String = + credentials.pairedToken ?: throw ApiException("Pair this phone first.", 401) + + private suspend fun api(method: String, path: String, body: JSONObject?, token: String?): JSONObject = + try { + JSONObject(apiRaw(method, path, body, token)) + } catch (e: JSONException) { + throw ApiException("Your computer sent an unexpected reply.") + } + + private suspend fun apiRaw(method: String, path: String, body: JSONObject?, token: String?): String { + val link = credentials.link ?: throw ApiException("Not connected to a computer.") + if (token == null) throw ApiException("Not connected to a computer.") + val request = Request.Builder() + .url(link.baseUrl + path) + .header("Authorization", "Bearer $token") + .method(method, body?.toString()?.toRequestBody("application/json".toMediaType())) + .build() + val start = System.currentTimeMillis() + val (code, text) = withContext(Dispatchers.IO) { + try { + http.newCall(request).execute().use { it.code to it.body.string() } + } catch (e: IOException) { + _latencyMs.value = null + throw ApiException("Could not reach your computer. Check you're on the same network.") + } + } + val elapsed = System.currentTimeMillis() - start + _latencyMs.value = elapsed + if (code in 200..299) return text + val json = runCatching { JSONObject(text) }.getOrNull() + val error = json?.optString("error")?.takeIf { it.isNotEmpty() } + // 401 means the desktop no longer knows this token, so drop to view-only like a 4001 close. + // 403 only means this route is not open to the token (or to an older desktop), so the + // pairing stays. + if (code == 401 && token == credentials.pairedToken && path != "/api/pair/verify") { + credentials.clearPairedToken() + _computers.value = credentials.computers + reconnect() + throw ApiException("This phone is no longer paired. Pair again to continue.", code) + } + throw ApiException(error ?: "Request failed ($code).", code, json) + } + + /** + * Stream an agent's terminal while it is on screen. Only viewed terminals stream: opening one + * starts from the desktop's rendered snapshot, so nothing needs buffering in the background. + * Pair with [releaseTerminal]. + */ + fun getTerminalBuffer(agentId: String): TerminalBuffer { + val buffer = terminalBuffers.getOrPut(agentId) { TerminalBuffer(agentId) } + subscribeAgent(agentId) + return buffer + } + + /** Stop streaming a terminal that left the screen. */ + fun releaseTerminal(agentId: String) { + terminalBuffers.remove(agentId) + if (subscribedAgents.remove(agentId) && socketOpen) { + send(JSONObject().put("type", "unsubscribe").put("agentId", agentId)) + } + } + + private fun subscribeAgent(agentId: String) { + if (subscribedAgents.add(agentId) && socketOpen) { + send(JSONObject().put("type", "subscribe").put("agentId", agentId)) + } + } + + /** + * Size an agent's PTY to this phone's terminal view, so full-screen TUIs such as Claude Code fill + * the phone. Needs pairing. The desktop gets its size back on [releaseViewSize] or disconnect. + */ + fun setViewSize(agentId: String, cols: Int, rows: Int) { + viewSize?.let { if (it.first != agentId) releaseViewSize(it.first) } + if (viewSize == Triple(agentId, cols, rows)) return + viewSize = Triple(agentId, cols, rows) + viewSizeSent = false + sendViewSize() + } + + fun releaseViewSize(agentId: String) { + if (viewSize?.first != agentId) return + viewSize = null + if (!socketOpen) return + send(JSONObject().put("type", "view-size").put("agentId", agentId)) + // Resubscribe for a fresh snapshot, which carries the desktop's size again. + if (agentId in subscribedAgents) { + send(JSONObject().put("type", "unsubscribe").put("agentId", agentId)) + send(JSONObject().put("type", "subscribe").put("agentId", agentId)) + } + } + + /** + * The app left the screen but the socket stays open for notifications: give the desktop its + * size back until [resumeViewSize]. + */ + fun pauseViewSize() { + val agentId = viewSize?.first ?: return + if (socketOpen && viewSizeSent) send(JSONObject().put("type", "view-size").put("agentId", agentId)) + viewSizeSent = false + } + + fun resumeViewSize() = sendViewSize() + + /** Only paired sockets may size a PTY; the server closes a view-only one that tries. */ + private fun sendViewSize() { + val (agentId, cols, rows) = viewSize ?: return + if (viewSizeSent || !socketOpen || !_state.value.canControl) return + send(JSONObject().put("type", "view-size").put("agentId", agentId).put("cols", cols).put("rows", rows)) + viewSizeSent = true + terminalBuffers[agentId]?.resize(cols, rows) + } + + /** Stream an agent's terminal. Retained for backwards compatibility. */ + fun watchTerminal(agentId: String, listener: TerminalListener): () -> Unit { + getTerminalBuffer(agentId) + val listeners = terminalListeners.getOrPut(agentId) { mutableSetOf() } + listeners.add(listener) + return { + listeners.remove(listener) + if (listeners.isEmpty()) { + terminalListeners.remove(agentId) + } + } + } + + /** Type into an agent's terminal; `submit` presses Enter once the text has landed. */ + suspend fun sendInput(agentId: String, data: String, submit: Boolean) { + if (data.length > MAX_INPUT_LENGTH) { + throw IOException("This message is too long. Shorten it and try again.") + } + request( + JSONObject() + .put("type", "input") + .put("agentId", agentId) + .put("data", data) + .put("submit", submit), + ) + } + + /** + * Follow a built-in chat's conversation, as the phone web UI does. Call [unwatchChat] when done; + * the state stays null until the desktop sends the first frame. + */ + fun watchChat(agentId: String): StateFlow { + val flow = chats.getOrPut(agentId) { MutableStateFlow(null) } + val watchers = chatWatchers[agentId] ?: 0 + chatWatchers[agentId] = watchers + 1 + if (watchers == 0 && socketOpen) send(JSONObject().put("type", "chat-subscribe").put("agentId", agentId)) + return flow.asStateFlow() + } + + fun unwatchChat(agentId: String) { + val watchers = (chatWatchers[agentId] ?: return) - 1 + if (watchers > 0) { + chatWatchers[agentId] = watchers + return + } + chatWatchers.remove(agentId) + chats.remove(agentId) + if (socketOpen) send(JSONObject().put("type", "chat-unsubscribe").put("agentId", agentId)) + } + + /** One of the desktop's chat actions (send, interrupt, respond, …); needs pairing. */ + suspend fun sendChatAction(agentId: String, action: String, params: JSONObject = JSONObject()) { + if (params.toString().toByteArray().size > MAX_CHAT_MESSAGE_LENGTH) { + throw IOException("This message is too long to send from a phone.") + } + request( + JSONObject() + .put("type", "chat-action") + .put("agentId", agentId) + .put("action", action) + .put("params", params), + ) + } + + /** Send a message the server confirms with an input-result for its requestId. */ + private suspend fun request(msg: JSONObject) { + val ws = socket + if (!_state.value.canControl || ws == null) { + throw IOException("Reconnect before sending. Your draft has been kept.") + } + val requestId = (++nextRequestId).toString() + val result = CompletableDeferred() + pending[requestId] = result + msg.put("requestId", requestId) + if (!ws.send(msg.toString())) { + pending.remove(requestId) + throw IOException("Could not send. Your draft has been kept.") + } + try { + withTimeout(REQUEST_TIMEOUT_MS) { result.await() } + } catch (e: TimeoutCancellationException) { + throw IOException( + "Delivery could not be confirmed. Check the output before retrying; your draft has been kept.", + ) + } finally { + pending.remove(requestId) + } + } + + private fun connect() { + if (socket != null) return + val link = credentials.link ?: return + val paired = credentials.pairedToken + authKind = if (paired != null) TokenKind.PAIRED else TokenKind.MOBILE + val token = paired ?: link.token + + reconnectJob?.cancel() + _state.update { it.copy(status = ConnectionStatus.CONNECTING, canControl = false, linkExpired = false) } + val request = Request.Builder().url(link.webSocketUrl).build() + lateinit var ws: WebSocket + ws = http.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + // Authenticate in the first message, not the URL, so the token stays out of logs. + webSocket.send(JSONObject().put("type", "auth").put("token", token).toString()) + scope.launch { + if (socket !== ws) return@launch + socketOpen = true + viewSizeSent = false + subscribedAgents.forEach { + send(JSONObject().put("type", "subscribe").put("agentId", it)) + } + chatWatchers.keys.forEach { + send(JSONObject().put("type", "chat-subscribe").put("agentId", it)) + } + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val msg = parseServerMessage(text) ?: return + scope.launch { if (socket === ws) handle(msg) } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, null) + scope.launch { if (socket === ws) onDisconnect(code) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + scope.launch { if (socket === ws) onDisconnect(CLOSE_ABNORMAL) } + } + }) + socket = ws + // A sleeping phone or unreachable computer may never finish the handshake. + handshakeJob = scope.launch { + delay(HANDSHAKE_TIMEOUT_MS) + if (socket === ws && _state.value.status != ConnectionStatus.CONNECTED) { + onDisconnect(CLOSE_ABNORMAL) + } + } + } + + private fun handle(msg: ServerMessage) { + when (msg) { + is ServerMessage.Agents -> { + handshakeJob?.cancel() + _state.update { + it.copy(status = ConnectionStatus.CONNECTED, canControl = authKind == TokenKind.PAIRED) + } + _agents.value = msg.list + sendViewSize() + + // Clean up deleted agents that no longer exist on the desktop + val activeIds = msg.list.map { it.agentId }.toSet() + val deleted = subscribedAgents.filter { it !in activeIds } + deleted.forEach { id -> + subscribedAgents.remove(id) + terminalBuffers.remove(id) + terminalListeners.remove(id) + if (socketOpen) { + send(JSONObject().put("type", "unsubscribe").put("agentId", id)) + } + } + } + is ServerMessage.Status -> { + _agents.update { list -> + list.map { if (it.agentId == msg.agentId) it.copy(running = msg.running, exitCode = msg.exitCode) else it } + } + if (msg.running) { + getTerminalBuffer(msg.agentId) + } + } + is ServerMessage.Scrollback -> { + val buffer = terminalBuffers.getOrPut(msg.agentId) { TerminalBuffer(msg.agentId) } + buffer.onScrollback(msg.data, msg.cols, msg.rows) + // A snapshot sent before the server applied this phone's size carries the desktop's. + viewSize?.let { (agentId, cols, rows) -> if (agentId == msg.agentId && viewSizeSent) buffer.resize(cols, rows) } + terminalListeners[msg.agentId]?.toList()?.forEach { it.onScrollback(msg.data, msg.cols, msg.rows) } + } + is ServerMessage.Output -> { + terminalBuffers[msg.agentId]?.onOutput(msg.data) + terminalListeners[msg.agentId]?.toList()?.forEach { it.onOutput(msg.data) } + } + is ServerMessage.Chat -> chats[msg.agentId]?.value = msg.state + is ServerMessage.InputResult -> { + val result = pending.remove(msg.requestId) ?: return + if (msg.ok) { + result.complete(Unit) + } else { + result.completeExceptionally(IOException(msg.error ?: "Could not send. Your draft has been kept.")) + } + } + } + } + + private fun onDisconnect(code: Int) { + closeSocket() + // 4001: the desktop rejected the token. A stale paired token falls back to view-only; a + // stale QR-code token needs a fresh scan. 4003: this phone lost its typing rights. + when { + code == CLOSE_UNAUTHORIZED && authKind == TokenKind.MOBILE -> { + credentials.clear() + _computers.value = credentials.computers + _agents.value = emptyList() + _state.value = ConnectionState(linkExpired = true) + return + } + code == CLOSE_UNAUTHORIZED || code == CLOSE_FORBIDDEN -> { + credentials.clearPairedToken() + _computers.value = credentials.computers + if (code == CLOSE_UNAUTHORIZED) { + if (started) connect() + return + } + } + } + if (!started) return + reconnectJob = scope.launch { + delay(RECONNECT_DELAY_MS) + connect() + } + } + + private fun closeSocket() { + handshakeJob?.cancel() + reconnectJob?.cancel() + socketOpen = false + socket?.let { + socket = null + it.close(CLOSE_NORMAL, null) + } + _state.update { it.copy(status = ConnectionStatus.DISCONNECTED, canControl = false) } + _latencyMs.value = null + val interrupted = IOException( + "Connection interrupted. Your message may have reached the terminal. Check the output before retrying.", + ) + pending.values.forEach { it.completeExceptionally(interrupted) } + pending.clear() + } + + private fun send(msg: JSONObject) { + if (socketOpen) socket?.send(msg.toString()) + } + + private companion object { + const val CLOSE_NORMAL = 1000 + const val CLOSE_ABNORMAL = 1006 + const val CLOSE_UNAUTHORIZED = 4001 + const val CLOSE_FORBIDDEN = 4003 + const val HANDSHAKE_TIMEOUT_MS = 10_000L + const val RECONNECT_DELAY_MS = 3_000L + const val REQUEST_TIMEOUT_MS = 10_000L + + // 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 + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Screens.kt b/android/app/src/main/java/com/parallelcode/phone/Screens.kt new file mode 100644 index 00000000..d72a0ef7 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Screens.kt @@ -0,0 +1,1636 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.draw.clip +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import kotlinx.coroutines.delay +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.layout.heightIn +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalDensity +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import kotlinx.coroutines.launch +import java.io.IOException + +private const val NOT_A_LINK = "That isn't a Parallel Code link. Open Connect Phone on your computer and try again." + +@Composable +fun ConnectScreen( + expired: Boolean, + onLink: (ConnectionLink) -> Unit, + saved: List = emptyList(), + onSelectSaved: (String) -> Unit = {}, + onCancel: (() -> Unit)? = null, +) { + val context = LocalContext.current + var pasted by rememberSaveable { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + fun accept(raw: String) { + val link = ConnectionLink.parse(raw) + if (link == null) error = NOT_A_LINK else onLink(link) + } + + SetupPage(title = "Connect to your computer") { + if (expired) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + "This link no longer works. Your computer restarted Remote Access or disconnected this phone.", + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Text( + "On your computer, open Connect Phone and scan the QR code.", + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Button( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + error = null + val options = GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + GmsBarcodeScanning.getClient(context, options).startScan() + .addOnSuccessListener { accept(it.rawValue.orEmpty()) } + .addOnFailureListener { error = "The scanner isn't available. Paste the link instead." } + }, + ) { Text("Scan QR code", fontWeight = FontWeight.SemiBold) } + Text( + "Or paste the link shown under the QR code:", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + OutlinedTextField( + value = pasted, + onValueChange = { pasted = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("http://192.168.1.20:7777/?token=…") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + TextButton(onClick = { accept(pasted) }, enabled = pasted.isNotBlank()) { + Text("Connect", fontWeight = FontWeight.SemiBold) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (saved.isNotEmpty()) { + Text( + "Or use a computer you've linked before:", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + saved.forEach { computer -> + OutlinedButton(onClick = { onSelectSaved(computer.baseUrl) }, modifier = Modifier.fillMaxWidth()) { + Text(computer.label, fontFamily = FontFamily.Monospace) + } + } + } + onCancel?.let { TextButton(onClick = it) { Text("Cancel") } } + } +} + +@Composable +fun PairScreen(pair: suspend (pin: String, remember: Boolean) -> Unit, onDone: () -> Unit) { + val scope = rememberCoroutineScope() + var pin by rememberSaveable { mutableStateOf("") } + var remember by rememberSaveable { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + SetupPage(title = "Enable replies") { + Text( + "Enter the six-digit code from Connect Phone on your computer to send messages to agents.", + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = pin, + onValueChange = { pin = it.filter(Char::isDigit).take(6) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Code from your computer") }, + singleLine = true, + enabled = !busy, + textStyle = MaterialTheme.typography.headlineSmall.copy(fontFamily = FontFamily.Monospace), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Keep this phone authorized", fontWeight = FontWeight.Medium) + Text( + "Stay paired after your computer restarts. Use only on a phone you trust.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = remember, + onCheckedChange = { remember = it }, + enabled = !busy, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + modifier = Modifier.fillMaxWidth(), + enabled = pin.length == 6 && !busy, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + busy = true + error = null + scope.launch { + try { + pair(pin, remember) + onDone() + } catch (e: ApiException) { + error = e.message + } finally { + busy = false + } + } + }, + ) { Text(if (busy) "Authorizing…" else "Enable replies", fontWeight = FontWeight.SemiBold) } + TextButton(modifier = Modifier.fillMaxWidth(), onClick = onDone, enabled = !busy) { + Text("Continue viewing only") + } + } +} + +@Composable +private fun SetupPage(title: String, content: @Composable () -> Unit) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + content() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentsScreen( + client: RemoteClient, + host: String, + state: ConnectionState, + agents: List, + showMinimizedTasks: Boolean, + onOpen: (RemoteAgent) -> Unit, + onPair: () -> Unit, + onNewTask: () -> Unit, + onSettings: () -> Unit, + computers: List = emptyList(), + onSwitchComputer: (String) -> Unit = {}, +) { + var refreshing by remember { mutableStateOf(false) } + var showSwitchMenu by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val activeAgents = remember(agents) { agents.filter { !it.collapsed } } + val minimizedAgents = remember(agents) { agents.filter { it.collapsed } } + val showHostSwitch = computers.size > 1 + val hostLabel = computers.firstOrNull { it.baseUrl == host }?.label + ?: host.substringAfter("://") + val latencyMs by client.latencyMs.collectAsState() + val latencySegment = + if (state.status == ConnectionStatus.CONNECTED && latencyMs != null) " · $latencyMs ms" else "" + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Column { + Text( + "Agents", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val statusDotColor = when (state.status) { + ConnectionStatus.CONNECTED -> AppTheme.extra.success + ConnectionStatus.CONNECTING -> AppTheme.extra.warningText + ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error + } + val infiniteTransition = rememberInfiniteTransition(label = "connPulse") + val pulseAlpha by if (state.status == ConnectionStatus.CONNECTED) { + infiniteTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "connDotAlpha", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(statusDotColor.copy(alpha = pulseAlpha)), + ) + Text( + "${statusLabel(state)}$latencySegment · $hostLabel", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + }, + actions = { + if (showHostSwitch) { + Box { + TextButton(onClick = { showSwitchMenu = true }) { + Text("Switch", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium) + } + DropdownMenu( + expanded = showSwitchMenu, + onDismissRequest = { showSwitchMenu = false }, + ) { + computers.forEach { computer -> + val inUse = computer.baseUrl == host + DropdownMenuItem( + text = { + Column { + Text( + computer.label, + fontFamily = FontFamily.Monospace, + fontWeight = if (inUse) FontWeight.SemiBold else FontWeight.Normal, + ) + Text( + (if (inUse) "In use · " else "") + if (computer.pairedToken != null) "Paired" else "View only", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + }, + onClick = { + showSwitchMenu = false + if (!inUse) onSwitchComputer(computer.baseUrl) + }, + trailingIcon = if (inUse) { + { + Text( + "✓", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } else null, + ) + } + } + } + } + if (state.canControl) { + Button( + onClick = onNewTask, + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.padding(end = 4.dp), + ) { + Text("New task", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) + } + } + TextButton(onClick = onSettings) { + Text("Settings", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium) + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + bottomBar = { + if (showMinimizedTasks && minimizedAgents.isNotEmpty()) { + MinimizedTasksBottomBar( + minimizedAgents = minimizedAgents, + onOpen = onOpen, + ) + } + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + scope.launch { + client.reconnect() + try { client.fetchUsage() } catch (_: Exception) {} + delay(600) + refreshing = false + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + LazyColumn( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + item { UsageStrip(client, connected = state.status == ConnectionStatus.CONNECTED) } + if (state.status == ConnectionStatus.CONNECTED && !state.canControl) { + item { PairBanner(onPair) } + } + if (state.status == ConnectionStatus.CONNECTED && activeAgents.isEmpty()) { + item { + Text( + if (showMinimizedTasks && minimizedAgents.isNotEmpty()) "No active agents running." else "No agents are running.", + Modifier.padding(24.dp), + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + if (state.status != ConnectionStatus.CONNECTED && activeAgents.isEmpty() && (!showMinimizedTasks || minimizedAgents.isEmpty())) { + item { + Row( + Modifier.padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + Modifier.size(20.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Reaching your computer…", + color = AppTheme.extra.textMuted, + ) + } + } + } + items(activeAgents, key = { it.agentId }) { agent -> + AgentCard(agent, onOpen, modifier = Modifier.animateItem()) + } + } + } + } +} + +@Composable +private fun MinimizedTasksBottomBar( + minimizedAgents: List, + onOpen: (RemoteAgent) -> Unit, + modifier: Modifier = Modifier, +) { + var isExpanded by rememberSaveable { mutableStateOf(true) } + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(AppTheme.extra.textMuted), + ) + Text( + "Minimized (${minimizedAgents.size})", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + if (isExpanded) "Hide" else "Show", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(animationSpec = tween(220, easing = FastOutSlowInEasing)) + fadeIn(tween(180)), + exit = shrinkVertically(animationSpec = tween(200, easing = FastOutSlowInEasing)) + fadeOut(tween(150)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + minimizedAgents.forEach { agent -> + MinimizedTaskCard( + agent = agent, + onClick = { onOpen(agent) }, + single = minimizedAgents.size == 1, + ) + } + } + } + } + } +} + +@Composable +private fun MinimizedTaskCard( + agent: RemoteAgent, + onClick: () -> Unit, + single: Boolean, +) { + Card( + modifier = Modifier + .width(if (single) 260.dp else 220.dp) + .clickable(onClick = onClick), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = AppTheme.extra.cardBg), + border = BorderStroke(1.dp, AppTheme.extra.borderSubtle), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = agent.taskName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.extra.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "Minimized", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = AppTheme.extra.textMuted, + ) + } + listOfNotNull(agent.projectName, agent.agentName).takeIf { it.isNotEmpty() }?.let { + Text( + text = it.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = AppTheme.extra.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (agent.lastLine.isNotBlank()) { + Text( + text = agent.lastLine, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, lineHeight = 14.sp), + fontFamily = FontFamily.Monospace, + color = AppTheme.extra.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +internal fun PairBanner(onPair: () -> Unit) { + Card( + modifier = Modifier.padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = AppTheme.extra.warningBannerBg), + border = BorderStroke(1.dp, AppTheme.extra.attentionBorder), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "View only. Pair to send replies.", + Modifier.weight(1f), + color = AppTheme.extra.warningText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Button( + onClick = onPair, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = AppTheme.extra.attentionBorder, + contentColor = AppTheme.extra.warningText, + ), + ) { Text("Pair", fontWeight = FontWeight.Bold) } + } + } +} + +@Composable +private fun AgentCard( + agent: RemoteAgent, + onOpen: (RemoteAgent) -> Unit, + modifier: Modifier = Modifier, +) { + val isAttention = !agent.collapsed && (agent.attention == "needs_input" || agent.attention == "error") + val attentionTransition = rememberInfiniteTransition(label = "attentionPulse") + val attentionGlow by if (isAttention) { + attentionTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "attentionGlow", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + + val cardBorderColor = if (isAttention) { + if (agent.attention == "error") MaterialTheme.colorScheme.error.copy(alpha = attentionGlow) + else AppTheme.extra.attentionBorder.copy(alpha = attentionGlow) + } else { + AppTheme.extra.border + } + val cardBg by animateColorAsState( + if (isAttention) AppTheme.extra.cardBgAttention else AppTheme.extra.cardBg, + animationSpec = tween(300), + label = "cardBg", + ) + // A card that just started needing you bumps once, so the change catches the eye. + val bump = remember { Animatable(1f) } + LaunchedEffect(isAttention) { + if (!isAttention) return@LaunchedEffect + bump.animateTo(1.03f, tween(140)) + bump.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + } + + val (statusColor, statusText) = when { + agent.collapsed -> Pair(AppTheme.extra.textMuted, "Minimized") + !agent.running -> Pair(AppTheme.extra.textMuted, agent.exitCode?.let { "Exited ($it)" } ?: "Exited") + agent.attention == "needs_input" -> Pair(AppTheme.extra.warningText, "Needs input") + agent.attention == "error" -> Pair(MaterialTheme.colorScheme.error, "Error") + agent.attention == "active" -> Pair(MaterialTheme.colorScheme.primary, "Working") + agent.attention == "shell_busy" -> Pair(MaterialTheme.colorScheme.primary, "Running command") + agent.attention == "ready" -> Pair(AppTheme.extra.success, "Ready") + agent.attention == "review" -> Pair(AppTheme.extra.review, "Review") + else -> Pair(AppTheme.extra.textMuted, "Idle") + } + + val isWorking = !agent.collapsed && agent.running && (agent.attention == "active" || agent.attention == "shell_busy") + val infiniteTransition = rememberInfiniteTransition(label = "agentWorkingPulse") + val dotAlpha by if (isWorking) { + infiniteTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "workingDotAlpha", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .graphicsLayer { + scaleX = bump.value + scaleY = bump.value + } + .clickable { onOpen(agent) }, + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = cardBg), + border = BorderStroke(1.dp, cardBorderColor), + ) { + Column( + Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + agent.taskName, + Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.extra.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( + shape = MaterialTheme.shapes.large, + color = statusColor.copy(alpha = 0.12f), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.35f)), + ) { + Row( + Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(statusColor.copy(alpha = dotAlpha)), + ) + Text( + statusText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = statusColor, + ) + } + } + } + listOfNotNull(agent.projectName, agent.agentName).takeIf { it.isNotEmpty() }?.let { + Text( + it.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + val detail = agent.lastLine.ifBlank { if (agent.isChat) "Built-in chat" else "" } + if (detail.isNotBlank()) { + Box( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.background) + .border(BorderStroke(1.dp, AppTheme.extra.borderSubtle), MaterialTheme.shapes.small) + .padding(horizontal = 10.dp, vertical = 7.dp), + ) { + Text( + detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + fontFamily = FontFamily.Monospace, + color = AppTheme.extra.textMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentScreen( + agent: RemoteAgent?, + agentId: String, + state: ConnectionState, + client: RemoteClient, + alwaysFollowOutput: Boolean, + fitTerminalToPhone: Boolean, + quickReplies: List, + sendQuickReplies: Boolean = false, + promptHistory: PromptHistoryStore? = null, + pageLabel: String? = null, + onBack: () -> Unit, + onPair: () -> Unit, +) { + val buffer = remember(agentId) { client.getTerminalBuffer(agentId) } + 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() } + var tab by rememberSaveable { mutableStateOf(AgentTab.TERMINAL) } + var closing by remember { mutableStateOf(false) } + var merging by remember { mutableStateOf(false) } + var viewSize by remember { mutableStateOf?>(null) } + + // With "Fit the terminal to this phone" on and paired, the PTY takes this screen's size so + // full-screen TUIs fill the phone; leaving + // the screen hands it back to the desktop. Settle first: the keyboard animates the height. + val sizeTerminal = fitTerminalToPhone && state.canControl && agent != null && agent.running && agent.collapsed != true + LaunchedEffect(sizeTerminal, viewSize) { + val (cols, rows) = viewSize ?: return@LaunchedEffect + if (!sizeTerminal) { + client.releaseViewSize(agentId) + return@LaunchedEffect + } + delay(300) + client.setViewSize(agentId, cols, rows) + } + DisposableEffect(agentId) { + onDispose { + client.releaseTerminal(agentId) + client.releaseViewSize(agentId) + } + } + + if (merging && agent != null) { + MergeTaskDialog( + taskId = agent.taskId, + client = client, + onDismiss = { merging = false }, + onMerged = { + merging = false + onBack() + }, + ) + } + + if (closing && agent != null) { + CloseTaskDialog( + taskName = agent.taskName, + close = { force -> client.closeTask(agent.taskId, force) }, + onDismiss = { closing = false }, + onClosed = { + closing = false + onBack() + }, + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + navigationIcon = { + TextButton(onClick = onBack) { + Text("Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Column { + Text( + agent?.taskName ?: "Agent", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + (agent?.let(::agentStatusLabel) ?: statusLabel(state)) + (pageLabel?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + }, + actions = { + if (agent != null && state.canControl) { + TextButton(onClick = { merging = true }) { + Text("Merge", fontWeight = FontWeight.SemiBold) + } + TextButton(onClick = { closing = true }) { + Text("Close", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold) + } + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + ) { + PrimaryTabRow( + selectedTabIndex = tab.ordinal, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + divider = { HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) }, + ) { + AgentTab.entries.forEach { + Tab( + selected = tab == it, + onClick = { tab = it }, + text = { + Text( + it.label, + fontWeight = if (tab == it) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } + AnimatedContent( + targetState = tab, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally(animationSpec = tween(220)) { width -> width / 4 } + fadeIn(tween(180))) + .togetherWith(slideOutHorizontally(animationSpec = tween(200)) { width -> -width / 4 } + fadeOut(tween(160))) + } else { + (slideInHorizontally(animationSpec = tween(220)) { width -> -width / 4 } + fadeIn(tween(180))) + .togetherWith(slideOutHorizontally(animationSpec = tween(200)) { width -> width / 4 } + fadeOut(tween(160))) + } + }, + modifier = Modifier.weight(1f), + label = "agentTabTransition", + ) { currentTab -> + when (currentTab) { + AgentTab.TERMINAL -> if (agent?.collapsed == true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "This task is minimized on your computer.", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Expand it on desktop to resume terminal interaction.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + } + } else { + TerminalText( + lines, + buffer.screen.cols, + Modifier.fillMaxSize(), + alwaysFollow = alwaysFollowOutput, + onViewSize = { cols, rows -> viewSize = cols to rows }, + ) + } + AgentTab.NOTES -> if (agent != null) { + NotesPane(agent.taskId, state.canControl, client, Modifier.fillMaxSize()) + } else { + Text( + "This agent is no longer running.", + Modifier + .fillMaxSize() + .padding(16.dp), + color = AppTheme.extra.textMuted, + ) + } + AgentTab.CHANGES -> if (agent != null) { + DiffPane(agent.taskId, client, Modifier.fillMaxSize()) + } else { + Text( + "This agent is no longer running.", + Modifier + .fillMaxSize() + .padding(16.dp), + color = AppTheme.extra.textMuted, + ) + } + } + } + if (!state.canControl) { + Spacer(Modifier.height(8.dp)) + PairBanner(onPair) + Spacer(Modifier.height(8.dp)) + } else if (tab == AgentTab.TERMINAL && agent?.collapsed != true) { + ReplyBox( + agentId = agentId, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, + working = agent?.running == true && + (agent.attention == "active" || agent.attention == "shell_busy"), + promptHistory = promptHistory, + send = { draft -> + val data = messageForTerminal(draft, buffer.screen.bracketedPaste) + if (data.isNotEmpty()) client.sendInput(agentId, data, submit = true) + }, + sendKey = { client.sendInput(agentId, it, submit = false) }, + ) + } + } + } +} + +/** + * Confirms closing a task, as the desktop's Close Task dialog does. The first confirm asks the + * desktop to close only if no work would be lost; if it answers with warnings, they are shown and + * a second confirm forces the close. + */ +@Composable +internal fun CloseTaskDialog( + taskName: String, + close: suspend (force: Boolean) -> List, + onDismiss: () -> Unit, + onClosed: () -> Unit, +) { + val scope = rememberCoroutineScope() + var warnings by remember { mutableStateOf>(emptyList()) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun submit() { + val force = warnings.isNotEmpty() + busy = true + error = null + scope.launch { + try { + val refused = close(force) + if (refused.isEmpty()) onClosed() else warnings = refused + } catch (e: ApiException) { + error = e.message + } finally { + busy = false + } + } + } + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { Text("Close task?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "“$taskName” closes on your computer: its agents and shells stop, and its worktree, if any, is removed.", + color = AppTheme.extra.textMuted, + ) + warnings.forEach { + Text( + it, + Modifier + .fillMaxWidth() + .background(AppTheme.extra.warningBannerBg) + .padding(horizontal = 10.dp, vertical = 8.dp), + color = AppTheme.extra.warningText, + fontWeight = FontWeight.SemiBold, + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton(onClick = ::submit, enabled = !busy) { + if (busy) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + if (warnings.isEmpty()) "Close" else "Close anyway", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold, + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } + }, + ) +} + +private enum class AgentTab(val label: String) { TERMINAL("Terminal"), CHANGES("Changes"), NOTES("Notes") } + +/** Keys agent TUIs ask for that a phone keyboard can't type, as in the phone web UI. */ +private val QUICK_KEYS = listOf( + "Enter" to "\r", + "Esc" to "\u001b", + "→" to "\u001b[C", + "↑" to "\u001b[A", + "↓" to "\u001b[B", + "/" to "/", + "Ctrl+C" to "\u0003", + "Ctrl+D" to "\u0004", + "Clear" to "\u000c", +) + +@Composable +private fun TerminalText( + lines: List>, + cols: Int, + modifier: Modifier, + alwaysFollow: Boolean, + onViewSize: (cols: Int, rows: Int) -> Unit, +) { + // The terminal follows the active look, as it does on the desktop: its own ANSI + // set over the look's panel background. + val look = AppTheme.palette + val palette = remember(look) { TerminalPalette.forLook(look) } + // A lazy list lays out only the lines on screen; history lines keep their style runs between + // frames, so a spinner repainting one row no longer rebuilds thousands of lines. + val list = rememberLazyListState() + val horizontal = rememberScrollState() + val scope = rememberCoroutineScope() + var follow by remember { mutableStateOf(true) } + val isNearBottom by remember { derivedStateOf { !list.canScrollForward } } + var hasNewOutputWhileScrolled by remember { mutableStateOf(false) } + + // 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 + // an agent that redraws constantly (Claude's spinner) snapped the view back on its next frame. + // With "Always scroll to latest output" on, a scroll only holds the view while the finger is down. + LaunchedEffect(list.isScrollInProgress, alwaysFollow) { + if (!list.isScrollInProgress) { + follow = alwaysFollow || isNearBottom + if (follow) hasNewOutputWhileScrolled = false + } + } + + LaunchedEffect(lines) { + if (follow && !list.isScrollInProgress) { + withFrameNanos {} + // A scroll may have started or ended during that frame. + if (follow && !list.isScrollInProgress && lines.isNotEmpty()) list.scrollToItem(lines.lastIndex) + } else if (!isNearBottom) { + hasNewOutputWhileScrolled = true + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(Color(palette.background)), + ) { + // The PTY keeps the desktop's size, which rarely matches the phone. Size the font so its + // columns span the screen width (within readable bounds), and anchor a screen shorter + // than the view to the bottom, next to the reply box, as a terminal window would. + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val paddingPx = with(density) { (TERMINAL_PADDING_H * 2).roundToPx() } + val textWidthPx = constraints.maxWidth - paddingPx + val charPxAt10 = remember(measurer) { + measurer.measure("0".repeat(10), TextStyle(fontFamily = FontFamily.Monospace, fontSize = 10.sp)).size.width / 10f + } + val fontSize = (10f * textWidthPx / (cols.coerceAtLeast(1) * charPxAt10)).coerceIn(8f, 14f).sp + // Wide terminals at the smallest font scroll sideways; the lazy list needs a fixed width. + val contentWidth = with(density) { + maxOf(constraints.maxWidth, (cols * charPxAt10 * fontSize.value / 10f).toInt() + paddingPx).toDp() + } + + // The size this view fits at the default font, offered for the PTY. + val viewPaddingPx = with(density) { 16.dp.roundToPx() } + val viewHeightPx = constraints.maxHeight + LaunchedEffect(textWidthPx, viewHeightPx) { + val probe = measurer.measure( + "0".repeat(10), + TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT, lineHeight = TERMINAL_FONT * 1.27f), + ) + val viewCols = (textWidthPx / (probe.size.width / 10f)).toInt() + val viewRows = ((viewHeightPx - viewPaddingPx) / probe.size.height.toFloat()).toInt() + if (viewCols >= 20 && viewRows >= 5) onViewSize(viewCols, viewRows) + } + + Box( + Modifier + .fillMaxSize() + .horizontalScroll(horizontal), + ) { + LazyColumn( + state = list, + modifier = Modifier + .requiredWidth(contentWidth) + .fillMaxHeight(), + contentPadding = PaddingValues(horizontal = TERMINAL_PADDING_H, vertical = 8.dp), + verticalArrangement = Arrangement.Bottom, + ) { + items(lines.size) { i -> + val line = lines[i] + val text = remember(line, palette) { terminalLine(line, palette) } + Text( + text = text, + color = Color(palette.foreground), + fontFamily = FontFamily.Monospace, + fontSize = fontSize, + lineHeight = fontSize * 1.27f, + softWrap = false, + maxLines = 1, + ) + } + } + } + + // Vertical scrollbar indicator along the right edge + TerminalVerticalScrollbar( + list = list, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(end = 2.dp, top = 4.dp, bottom = 4.dp), + ) + + // Floating jump-to-bottom / new-output pill button + AnimatedVisibility( + visible = !isNearBottom, + enter = fadeIn(tween(180)) + slideInVertically(tween(200)) { it / 2 }, + exit = fadeOut(tween(150)) + slideOutVertically(tween(180)) { it / 2 }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 12.dp), + ) { + val jumpInteractionSource = remember { MutableInteractionSource() } + val isPressed by jumpInteractionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "jumpToBottomScale", + ) + Surface( + onClick = { + follow = true + hasNewOutputWhileScrolled = false + scope.launch { if (lines.isNotEmpty()) list.animateScrollToItem(lines.lastIndex) } + }, + interactionSource = jumpInteractionSource, + shape = RoundedCornerShape(20.dp), + color = if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.primary else AppTheme.extra.border), + shadowElevation = 6.dp, + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = if (hasNewOutputWhileScrolled) "↓ New output" else "↓ Latest", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.onPrimaryContainer else AppTheme.extra.textPrimary, + ) + } + } + } + } +} + +private val TERMINAL_PADDING_H = 12.dp +private val TERMINAL_FONT = 11.sp + +@Composable +private fun TerminalVerticalScrollbar( + list: LazyListState, + modifier: Modifier = Modifier, +) { + val info = list.layoutInfo + val total = info.totalItemsCount + val visible = info.visibleItemsInfo.size + if (total == 0 || visible >= total) return + BoxWithConstraints(modifier.width(4.dp)) { + val totalHeight = maxHeight + val thumbHeightDp = totalHeight * (visible.toFloat() / total).coerceIn(0.08f, 0.9f) + val scrollRatio = (list.firstVisibleItemIndex.toFloat() / (total - visible)).coerceIn(0f, 1f) + Box( + Modifier + .offset(y = (totalHeight - thumbHeightDp) * scrollRatio) + .fillMaxWidth() + .height(thumbHeightDp) + .clip(RoundedCornerShape(2.dp)) + .background( + if (list.isScrollInProgress) AppTheme.extra.textPrimary.copy(alpha = 0.55f) + else AppTheme.extra.textMuted.copy(alpha = 0.28f) + ), + ) + } +} + +private fun terminalLine(line: List, palette: TerminalPalette) = + buildAnnotatedString { + line.forEach { span -> + if (span.style == CellStyle.DEFAULT) { + append(span.text) + return@forEach + } + val s = palette.resolve(span.style) + withStyle( + SpanStyle( + color = Color(s.foreground), + background = s.background?.let(::Color) ?: Color.Unspecified, + fontWeight = if (s.bold) FontWeight.Bold else null, + fontStyle = if (s.italic) FontStyle.Italic else null, + textDecoration = when { + s.underline && s.strike -> TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough), + ) + s.underline -> TextDecoration.Underline + s.strike -> TextDecoration.LineThrough + else -> null + }, + ), + ) { append(span.text) } + } + } + +@Composable +private fun QuickKeyButton( + busy: Boolean, + onSend: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.92f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "quickKeyScale", + ) + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSend() + }, + enabled = !busy, + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, AppTheme.extra.border), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = AppTheme.extra.textPrimary, + ), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + content = content, + ) +} + +/** An arrow quick key ([Icons.Filled.KeyboardArrowUp] and friends), drawn larger than key text. */ +private fun arrowKeyFor(label: String): Pair? = when (label) { + "↑" -> Icons.Filled.KeyboardArrowUp to "Arrow up" + "↓" -> Icons.Filled.KeyboardArrowDown to "Arrow down" + "→" -> Icons.Filled.KeyboardArrowRight to "Arrow right" + else -> null +} + +@Composable +private fun ReplyBox( + agentId: String, + quickReplies: List, + sendQuickReplies: Boolean = false, + working: Boolean = false, + promptHistory: PromptHistoryStore? = null, + send: suspend (String) -> Unit, + sendKey: suspend (String) -> Unit, +) { + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + var draft by rememberSaveable { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var showHistory by remember { mutableStateOf(false) } + fun run(action: suspend () -> Unit) { + busy = true + error = null + scope.launch { + try { + action() + } catch (e: IOException) { + error = e.message + } finally { + busy = false + } + } + } + Surface( + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + QUICK_KEYS.forEach { (label, data) -> + val arrow = arrowKeyFor(label) + QuickKeyButton(busy = busy, onSend = { run { sendKey(data) } }) { + if (arrow != null) { + Icon(arrow.first, contentDescription = arrow.second, modifier = Modifier.size(20.dp)) + } else { + Text(label, fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } + } + } + QuickReplies(quickReplies, enabled = !busy) { + if (sendQuickReplies) run { send(it) } else draft = appendToDraft(draft, it) + } + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + Row( + modifier = Modifier.height(IntrinsicSize.Min), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Reply to agent", color = AppTheme.extra.textSubtle) }, + maxLines = 5, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + VoiceInputButton( + enabled = !busy, + modifier = Modifier.fillMaxHeight(), + ) { draft = appendToDraft(draft, it) } + if (promptHistory != null) { + OutlinedButton( + onClick = { showHistory = true }, + enabled = !busy && promptHistory.history(agentId).isNotEmpty(), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxHeight(), + ) { + Icon(Icons.Filled.History, contentDescription = "Recent messages") + } + } + if (working && draft.isBlank()) { + OutlinedButton( + // Interrupts the running command, like the Ctrl+C quick key. + onClick = { run { sendKey(3.toChar().toString()) } }, + enabled = !busy, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxHeight(), + ) { Icon(Icons.Filled.Stop, contentDescription = "Stop") } + } else { + val sendInteraction = remember { MutableInteractionSource() } + val sendPressed by sendInteraction.collectIsPressedAsState() + val sendScale by animateFloatAsState( + targetValue = if (sendPressed) 0.94f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "sendButtonScale", + ) + Button( + enabled = draft.isNotBlank() && !busy, + shape = MaterialTheme.shapes.large, + interactionSource = sendInteraction, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier + .fillMaxHeight() + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + val text = draft + run { + send(text) + promptHistory?.record(agentId, text) + draft = "" + } + }, + ) { Text("Send", fontWeight = FontWeight.Bold) } + } + } + if (showHistory) { + val history = remember(showHistory) { promptHistory?.history(agentId).orEmpty() } + PromptHistoryDialog( + history = history, + onPick = { + draft = appendToDraft(draft, it) + showHistory = false + }, + onDismiss = { showHistory = false }, + ) + } + } + } +} + +fun statusLabel(state: ConnectionState) = when (state.status) { + ConnectionStatus.CONNECTED -> if (state.canControl) "Connected" else "Connected, view only" + ConnectionStatus.CONNECTING -> "Connecting…" + ConnectionStatus.DISCONNECTED -> "Offline" +} + +internal fun agentStatusLabel(agent: RemoteAgent): String { + if (agent.collapsed) return "Minimized" + if (!agent.running) return agent.exitCode?.let { "Exited ($it)" } ?: "Exited" + return when (agent.attention) { + "needs_input" -> "Needs input" + "active" -> "Working" + "shell_busy" -> "Running command" + "error" -> "Error" + "ready" -> "Ready" + "review" -> "Review" + else -> "Idle" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt new file mode 100644 index 00000000..19024978 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt @@ -0,0 +1,1101 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + keepScreenOn: Boolean, + onKeepScreenOnChange: (Boolean) -> Unit, + keepScreenOnOnlyActive: Boolean, + onKeepScreenOnOnlyActiveChange: (Boolean) -> Unit, + themeMode: String, + onThemeModeChange: (String) -> Unit, + darkThemePreset: String, + onDarkThemePresetChange: (String) -> Unit, + lightThemePreset: String, + onLightThemePresetChange: (String) -> Unit, + showMinimizedTasks: Boolean, + onShowMinimizedTasksChange: (Boolean) -> Unit, + alwaysFollowOutput: Boolean, + onAlwaysFollowOutputChange: (Boolean) -> Unit, + fitTerminalToPhone: Boolean, + onFitTerminalToPhoneChange: (Boolean) -> Unit, + widgetTransparency: Int, + onWidgetTransparencyChange: (Int) -> Unit, + widgetPalette: String, + onWidgetPaletteChange: (String) -> Unit, + quickReplies: List, + onQuickRepliesChange: (List) -> Unit, + sendQuickReplies: Boolean, + onSendQuickRepliesChange: (Boolean) -> Unit, + notifications: NotificationPrefs, + onNotificationsChange: (NotificationPrefs) -> Unit, + latencyMs: Long?, + state: ConnectionState, + computers: List, + onSwitchComputer: (String) -> Unit, + onRenameComputer: (String, String?) -> Unit, + onForgetComputer: (String) -> Unit, + onAddComputer: () -> Unit, + onPair: () -> Unit, + onForget: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + navigationIcon = { + TextButton(onClick = onBack) { + Text("← Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Text( + "Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // DISPLAY SECTION + item { + SectionHeader("DISPLAY") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + "Keep screen awake", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Prevent the display from sleeping while Parallel Code is open.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = keepScreenOn, + onCheckedChange = onKeepScreenOnChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } + + AnimatedVisibility( + visible = keepScreenOn, + enter = expandVertically(animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(220)), + exit = shrinkVertically(animationSpec = tween(240, easing = FastOutSlowInEasing)) + fadeOut(tween(180)), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + "Only while tasks are active", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(2.dp)) + Text( + "Allow screen to sleep when all agents finish or are idle.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = keepScreenOnOnlyActive, + onCheckedChange = onKeepScreenOnOnlyActiveChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } + } + } + } + } + } + + // APPEARANCE SECTION + item { + SectionHeader("APPEARANCE") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + ThemeOptionRow( + title = "Follow system", + subtitle = "Match Android device theme settings", + selected = themeMode == SettingsStore.THEME_SYSTEM, + onClick = { onThemeModeChange(SettingsStore.THEME_SYSTEM) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + ThemeOptionRow( + title = "Always dark", + subtitle = "Keep dark looks even when the phone is in light mode", + selected = themeMode == SettingsStore.THEME_DARK, + onClick = { onThemeModeChange(SettingsStore.THEME_DARK) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + ThemeOptionRow( + title = "Always light", + subtitle = "Keep light looks even when the phone is in dark mode", + selected = themeMode == SettingsStore.THEME_LIGHT, + onClick = { onThemeModeChange(SettingsStore.THEME_LIGHT) }, + ) + } + } + } + + // Look presets, split by tone the way the desktop groups them. The + // slot being edited is the tone the phone is actually in, so tapping + // a swatch previews the look you are choosing. + item { + val systemDark = isSystemInDarkTheme() + val editingDark = when (themeMode) { + SettingsStore.THEME_DARK -> true + SettingsStore.THEME_LIGHT -> false + else -> systemDark + } + val selectedId = + if (editingDark) darkThemePreset else lightThemePreset + val onSelect: (String) -> Unit = + if (editingDark) onDarkThemePresetChange else onLightThemePresetChange + val presets = if (editingDark) LookPresets.dark else LookPresets.light + + SectionHeader(if (editingDark) "DARK LOOKS" else "LIGHT LOOKS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + presets.forEachIndexed { index, preset -> + if (index > 0) { + HorizontalDivider( + thickness = 1.dp, + color = AppTheme.extra.borderSubtle, + ) + } + LookPresetRow( + preset = preset, + selected = preset.id == selectedId, + onClick = { onSelect(preset.id) }, + ) + } + } + } + Spacer(Modifier.height(6.dp)) + Text( + text = "The same looks as the desktop app. Follow system picks your " + + "dark or light set automatically.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textSubtle, + ) + } + + // TASKS SECTION + item { + SectionHeader("TASKS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingSwitchRow( + title = "Show minimized tasks", + description = "Pin collapsed and minimized tasks to the bottom of the overview.", + checked = showMinimizedTasks, + onCheckedChange = onShowMinimizedTasksChange, + ) + } + } + } + + // NOTIFICATIONS SECTION + item { + SectionHeader("NOTIFICATIONS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingSwitchRow( + title = "Notify me about agents", + description = "Keeps the connection open in the background, with a quiet ongoing notification. Uses some battery.", + checked = notifications.enabled, + onCheckedChange = { onNotificationsChange(notifications.copy(enabled = it)) }, + ) + AnimatedVisibility( + visible = notifications.enabled, + enter = expandVertically(animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(220)), + exit = shrinkVertically(animationSpec = tween(240, easing = FastOutSlowInEasing)) + fadeOut(tween(180)), + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + SettingSwitchRow( + title = "Needs input", + description = "An agent is waiting for an answer or approval.", + checked = notifications.needsInput, + onCheckedChange = { onNotificationsChange(notifications.copy(needsInput = it)) }, + ) + SettingSwitchRow( + title = "Errors", + description = "An agent stopped on an error.", + checked = notifications.errors, + onCheckedChange = { onNotificationsChange(notifications.copy(errors = it)) }, + ) + SettingSwitchRow( + title = "Finished", + description = "An agent finished working or exited.", + checked = notifications.finished, + onCheckedChange = { onNotificationsChange(notifications.copy(finished = it)) }, + ) + } + } + } + } + } + + // TERMINAL SECTION + item { + SectionHeader("TERMINAL") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + QuickRepliesEditor(quickReplies, onQuickRepliesChange) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) + SettingSwitchRow( + title = "Send quick replies immediately", + description = "Tapping a quick reply sends it straight to the agent. Off, it is added to your draft first.", + checked = sendQuickReplies, + onCheckedChange = onSendQuickRepliesChange, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) + SettingSwitchRow( + title = "Always scroll to latest output", + description = "Jump to new output even after scrolling up. Off, the terminal follows output only while you are at the bottom.", + checked = alwaysFollowOutput, + onCheckedChange = onAlwaysFollowOutputChange, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) + SettingSwitchRow( + title = "Fit the terminal to this phone", + description = "Full-screen agents such as Claude Code fill the phone while you view them. Your computer's terminal is redrawn for the phone meanwhile and shifts until you leave.", + checked = fitTerminalToPhone, + onCheckedChange = onFitTerminalToPhoneChange, + ) + } + } + } + + // WIDGET SECTION + item { + SectionHeader("WIDGET") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Background transparency", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "$widgetTransparency%", + style = MaterialTheme.typography.bodyLarge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + "How much of your wallpaper shows through the home-screen widget. Its border fades with the card.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + Slider( + value = widgetTransparency.toFloat(), + onValueChange = { onWidgetTransparencyChange(widgetTransparencyStep(it.roundToInt())) }, + valueRange = WIDGET_TRANSPARENCY_STEPS.last().toFloat()..WIDGET_TRANSPARENCY_STEPS.first().toFloat(), + // The stops above are the only values, so the slider snaps between them. + steps = WIDGET_TRANSPARENCY_STEPS.size - 2, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = AppTheme.extra.inputBg, + inactiveTickColor = AppTheme.extra.border, + activeTickColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "${WIDGET_TRANSPARENCY_STEPS.last()}%", + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + Text( + "${WIDGET_TRANSPARENCY_STEPS.first()}%", + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + } + + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + + Text( + "Card color", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "The card's color. Its text colors follow so they stay readable.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + WIDGET_PALETTES.forEach { palette -> + WidgetSwatch( + palette = palette, + transparency = widgetTransparency, + selected = palette.key == widgetPalette, + onClick = { onWidgetPaletteChange(palette.key) }, + ) + } + } + } + } + } + + // DESKTOP CONNECTION SECTION + item { + SectionHeader("DESKTOP CONNECTION") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val host = state.link?.baseUrl ?: "Not connected" + val hostLabel = computers.firstOrNull { it.baseUrl == state.link?.baseUrl }?.label + ?: host.substringAfter("://") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Computer", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + hostLabel, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Status", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (state.status == ConnectionStatus.CONNECTED) AppTheme.extra.success else AppTheme.extra.textMuted), + ) + Text( + statusLabel(state), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (state.status == ConnectionStatus.CONNECTED) AppTheme.extra.success else AppTheme.extra.textMuted, + ) + } + } + + if (state.status == ConnectionStatus.CONNECTED) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Latency", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + latencyMs?.let { "${it} ms" } ?: "< 10 ms", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = AppTheme.extra.textPrimary, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Permissions", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + if (state.canControl) "Full control (paired)" else "View only", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (state.canControl) MaterialTheme.colorScheme.primary else AppTheme.extra.textMuted, + ) + } + + if (!state.canControl && state.status == ConnectionStatus.CONNECTED) { + Button( + onClick = onPair, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text("Pair with PIN to enable replies", fontWeight = FontWeight.SemiBold) + } + } + + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + + OutlinedButton( + onClick = onForget, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("Forget this computer", fontWeight = FontWeight.SemiBold) + } + } + } + } + + // COMPUTERS SECTION + item { + SectionHeader("COMPUTERS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { + computers.forEach { computer -> + ComputerRow( + computer = computer, + inUse = computer.baseUrl == state.link?.baseUrl, + onSelect = { onSwitchComputer(computer.baseUrl) }, + onRename = { onRenameComputer(computer.baseUrl, it) }, + onForget = { onForgetComputer(computer.baseUrl) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + } + TextButton(onClick = onAddComputer, modifier = Modifier.fillMaxWidth()) { + Text("Add another computer", fontWeight = FontWeight.SemiBold) + } + } + } + } + + // ABOUT SECTION + item { + SectionHeader("ABOUT") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Parallel Code", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Text("v3.1.0", color = AppTheme.extra.textMuted, fontFamily = FontFamily.Monospace) + } + Text( + "Mobile companion for monitoring and interacting with parallel AI agents.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } + } + } +} + +@Composable +private fun ThemeOptionRow( + title: String, + subtitle: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = AppTheme.extra.textMuted, + ), + ) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } +} + +/** + * One look preset in the settings list, with a live swatch drawn from that + * palette rather than from the active theme, so a swatch always shows the look it + * stands for. The desktop's cards show only a name and description; a phone needs + * the colors to tell fifteen looks apart. + */ +@Composable +private fun LookPresetRow( + preset: LookPalette, + selected: Boolean, + onClick: () -> Unit, +) { + val extra = AppTheme.extra + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LookSwatch(preset = preset, selected = selected) + Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) { + Text( + preset.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + preset.description, + style = MaterialTheme.typography.bodySmall, + color = extra.textMuted, + ) + } + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp).size(18.dp), + ) + } + } +} + +/** + * A miniature of the preset: its window background, a panel, and the accent and + * status dots. Radius comes from the preset too, so the square-edged looks read + * as different from the rounded ones at a glance. + */ +@Composable +private fun LookSwatch(preset: LookPalette, selected: Boolean) { + val borderColor = + if (selected) { + blendOver(AppTheme.extra.border, preset.accent, 0.72f) + } else { + AppTheme.extra.border + } + Box( + modifier = Modifier + .size(width = 52.dp, height = 40.dp) + .clip(RoundedCornerShape(preset.radiusIsland.coerceAtLeast(2.dp))) + .background(preset.bg) + .border(1.dp, borderColor, RoundedCornerShape(preset.radiusIsland.coerceAtLeast(2.dp))) + .padding(3.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(preset.radiusXs.coerceAtLeast(1.dp))) + .background(preset.panelBg) + .padding(3.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SwatchDot(preset.accent) + Spacer(Modifier.width(2.dp)) + SwatchDot(preset.success) + Spacer(Modifier.width(2.dp)) + SwatchDot(preset.warning) + } + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.SwatchDot(color: Color) { + Box( + modifier = Modifier + .size(5.dp) + .clip(CircleShape) + .background(color), + ) +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = AppTheme.extra.textMuted, + modifier = Modifier.padding(start = 4.dp), + ) +} + +@Composable +private fun SettingSwitchRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } +} + +/** A card color to tap: a preview of the widget in that color at the chosen transparency. */ +@Composable +private fun RowScope.WidgetSwatch( + palette: WidgetPalette, + transparency: Int, + selected: Boolean, + onClick: () -> Unit, +) { + // The card drawables hold the same alpha steps, so mirror it here over the settings surface. + val fill = Color(palette.fill).copy(alpha = widgetTransparencyStep(transparency) / 100f) + Column( + modifier = Modifier + .weight(1f) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.background) + .background(fill) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else AppTheme.extra.border, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + palette.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = Color(palette.headline), + ) + } + } +} + +/** One reply per line; saved when the field loses focus or the screen closes. */ +@Composable +private fun QuickRepliesEditor(replies: List, onChange: (List) -> Unit) { + var text by remember(replies) { mutableStateOf(replies.joinToString("\n")) } + val latest by rememberUpdatedState(text) + DisposableEffect(Unit) { onDispose { if (latest.lines() != replies) onChange(latest.lines()) } } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Quick replies", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Shown above the reply box; tap one to add it to your message. One per line.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (!it.isFocused) onChange(text.lines()) }, + minLines = 3, + maxLines = 8, + ) + } +} + +/** A saved computer: tap to switch to it; the one in use is marked and cannot be forgotten here. */ +@Composable +private fun ComputerRow( + computer: SavedComputer, + inUse: Boolean, + onSelect: () -> Unit, + onRename: (String?) -> Unit, + onForget: () -> Unit, +) { + var renaming by remember(computer.baseUrl) { mutableStateOf(false) } + if (renaming) { + RenameComputerDialog( + computer = computer, + onConfirm = { + onRename(it) + renaming = false + }, + onDismiss = { renaming = false }, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !inUse, onClick = onSelect) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = inUse, onClick = if (inUse) null else onSelect) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + Text( + computer.label, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (computer.alias?.isNotBlank() == true) { + Text( + computer.baseUrl.substringAfter("://"), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + Text( + (if (inUse) "In use · " else "") + if (computer.pairedToken != null) "Paired" else "View only", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + TextButton(onClick = { renaming = true }) { Text("Label") } + if (!inUse) { + TextButton(onClick = onForget) { Text("Forget", color = MaterialTheme.colorScheme.error) } + } + } +} + +/** Name a saved computer so it reads as something friendlier than its address. */ +@Composable +private fun RenameComputerDialog( + computer: SavedComputer, + onConfirm: (String?) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember(computer.baseUrl) { mutableStateOf(computer.alias.orEmpty()) } + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { Text("Label this computer") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Shown in the header, the switch menu and this list instead of the address.", + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Label (optional)") }, + placeholder = { Text(computer.baseUrl.substringAfter("://")) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(text.takeIf { it.isNotBlank() }) }) { + Text("Save", fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt new file mode 100644 index 00000000..96fb7c50 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt @@ -0,0 +1,138 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit + +class SettingsStore(private val prefs: SharedPreferences) { + + var keepScreenOn: Boolean + get() = prefs.getBoolean(KEY_KEEP_SCREEN_ON, false) + set(value) { + prefs.edit { putBoolean(KEY_KEEP_SCREEN_ON, value) } + } + + var keepScreenOnOnlyWhenActive: Boolean + get() = prefs.getBoolean(KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, false) + set(value) { + prefs.edit { putBoolean(KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, value) } + } + + /** + * Which tone to draw in: [THEME_SYSTEM] follows Android, the other two force it. + * The palette itself is [darkThemePreset] or [lightThemePreset], as on the desktop. + */ + var themeMode: String + get() = prefs.getString(KEY_THEME_MODE, THEME_SYSTEM) ?: THEME_SYSTEM + set(value) { + prefs.edit { putString(KEY_THEME_MODE, value) } + } + + /** Preset drawn while in dark mode; unknown or wrong-tone ids read back as Obsidian. */ + var darkThemePreset: String + get() = LookPresets.forTone(dark = true, id = prefs.getString(KEY_DARK_THEME_PRESET, null)).id + set(value) { + val preset = LookPresets.forTone(dark = true, id = value) + prefs.edit { putString(KEY_DARK_THEME_PRESET, preset.id) } + } + + /** Preset drawn while in light mode; unknown or wrong-tone ids read back as Obsidian Light. */ + var lightThemePreset: String + get() = LookPresets.forTone(dark = false, id = prefs.getString(KEY_LIGHT_THEME_PRESET, null)).id + set(value) { + val preset = LookPresets.forTone(dark = false, id = value) + prefs.edit { putString(KEY_LIGHT_THEME_PRESET, preset.id) } + } + + var showMinimizedTasks: Boolean + get() = prefs.getBoolean(KEY_SHOW_MINIMIZED_TASKS, false) + set(value) { + prefs.edit { putBoolean(KEY_SHOW_MINIMIZED_TASKS, value) } + } + + /** Jump to new terminal output even when scrolled up; off, output is followed only at the bottom. */ + var alwaysFollowOutput: Boolean + get() = prefs.getBoolean(KEY_ALWAYS_FOLLOW_OUTPUT, false) + set(value) { + prefs.edit { putBoolean(KEY_ALWAYS_FOLLOW_OUTPUT, value) } + } + + /** + * Give a viewed terminal the phone's size, so full-screen agents fill the phone. Off by default: + * while on, the desktop pane shows output drawn for the phone and shifts until you leave. + */ + var fitTerminalToPhone: Boolean + get() = prefs.getBoolean(KEY_FIT_TERMINAL, false) + set(value) = prefs.edit { putBoolean(KEY_FIT_TERMINAL, value) } + + /** Replies offered above the reply box, one per line. */ + var quickReplies: List + get() = (prefs.getString(KEY_QUICK_REPLIES, null) ?: DEFAULT_QUICK_REPLIES.joinToString("\n")) + .lines().map { it.trim() }.filter { it.isNotEmpty() } + set(value) = prefs.edit { putString(KEY_QUICK_REPLIES, value.joinToString("\n")) } + + /** Send a quick reply immediately when tapped, instead of adding it to the draft. */ + var sendQuickReplies: Boolean + get() = prefs.getBoolean(KEY_SEND_QUICK_REPLIES, false) + set(value) = prefs.edit { putBoolean(KEY_SEND_QUICK_REPLIES, value) } + + /** + * How opaque the home-screen widget's card is, as one of [WIDGET_TRANSPARENCY_STEPS]. Lower + * values show more wallpaper through the card; its border fades with the fill. + */ + var widgetTransparency: Int + get() = widgetTransparencyStep(prefs.getInt(KEY_WIDGET_TRANSPARENCY, 100)) + set(value) = prefs.edit { putInt(KEY_WIDGET_TRANSPARENCY, widgetTransparencyStep(value)) } + + /** The widget's card color, as a [WIDGET_PALETTES] key; unknown keys read back as Obsidian. */ + var widgetPalette: String + get() = widgetPalette(prefs.getString(KEY_WIDGET_PALETTE, null)).key + set(value) = prefs.edit { putString(KEY_WIDGET_PALETTE, widgetPalette(value).key) } + + /** Keep watching in the background and notify about agents (see [AgentWatchService]). */ + var notificationsEnabled: Boolean + get() = prefs.getBoolean(KEY_NOTIFICATIONS, false) + set(value) = prefs.edit { putBoolean(KEY_NOTIFICATIONS, value) } + + var notifyNeedsInput: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_NEEDS_INPUT, true) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_NEEDS_INPUT, value) } + + var notifyErrors: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_ERRORS, true) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_ERRORS, value) } + + var notifyFinished: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_FINISHED, false) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_FINISHED, value) } + + fun notifiesFor(event: AgentEvent): Boolean = when (event) { + AgentEvent.NEEDS_INPUT -> notifyNeedsInput + AgentEvent.ERROR -> notifyErrors + AgentEvent.FINISHED -> notifyFinished + } + + companion object { + const val PREFS_NAME = "settings" + const val KEY_KEEP_SCREEN_ON = "keepScreenOn" + const val KEY_KEEP_SCREEN_ON_ONLY_ACTIVE = "keepScreenOnOnlyActive" + const val KEY_THEME_MODE = "themeMode" + const val KEY_DARK_THEME_PRESET = "darkThemePreset" + const val KEY_LIGHT_THEME_PRESET = "lightThemePreset" + const val KEY_SHOW_MINIMIZED_TASKS = "showMinimizedTasks" + const val KEY_ALWAYS_FOLLOW_OUTPUT = "alwaysFollowOutput" + const val KEY_FIT_TERMINAL = "fitTerminalToPhone" + const val KEY_QUICK_REPLIES = "quickReplies" + const val KEY_SEND_QUICK_REPLIES = "sendQuickReplies" + val DEFAULT_QUICK_REPLIES = listOf("continue", "yes", "run the tests", "commit this") + const val KEY_NOTIFICATIONS = "notifications" + const val KEY_NOTIFY_NEEDS_INPUT = "notifyNeedsInput" + const val KEY_NOTIFY_ERRORS = "notifyErrors" + const val KEY_NOTIFY_FINISHED = "notifyFinished" + const val KEY_WIDGET_TRANSPARENCY = "widgetTransparency" + const val KEY_WIDGET_PALETTE = "widgetPalette" + + const val THEME_SYSTEM = "system" + const val THEME_DARK = "dark" + const val THEME_LIGHT = "light" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt b/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt new file mode 100644 index 00000000..4a11802f --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt @@ -0,0 +1,74 @@ +package com.parallelcode.phone + +import org.json.JSONObject + +/** One changed file in a unified diff, as the desktop's diff view lists it. */ +data class DiffFile(val path: String, val added: Int, val removed: Int, val lines: List, val binary: Boolean) + +/** + * The desktop's answer to a diff request. `unsupported` marks a task that edits + * the project folder in place, so it has no branch of its own to compare. + */ +data class TaskDiff(val diff: String, val truncated: Boolean, val unsupported: Boolean) { + companion object { + fun from(json: JSONObject) = TaskDiff( + diff = json.optString("diff"), + truncated = json.optBoolean("truncated"), + unsupported = json.optBoolean("unsupported"), + ) + } +} + +/** Split `git diff` output into files; hunk lines keep their ' ', '+', '-' or '@@' prefix. */ +fun parseUnifiedDiff(diff: String): List { + val files = mutableListOf() + var path: String? = null + var lines = mutableListOf() + var added = 0 + var removed = 0 + var binary = false + var inHunk = false + + fun finish() { + path?.let { files.add(DiffFile(it, added, removed, lines, binary)) } + lines = mutableListOf() + added = 0 + removed = 0 + binary = false + inHunk = false + } + + for (line in diff.lineSequence()) { + when { + line.startsWith("diff --git ") -> { + finish() + // "diff --git a/old b/new": the new path, which +++ may refine below. + path = line.substringAfter(" b/", line.removePrefix("diff --git ")) + } + !inHunk && line.startsWith("+++ ") -> { + val target = line.removePrefix("+++ ") + if (target != "/dev/null") path = target.removePrefix("b/") + } + !inHunk && line.startsWith("--- ") -> { + val source = line.removePrefix("--- ") + if (path == null && source != "/dev/null") path = source.removePrefix("a/") + } + line.startsWith("Binary files ") -> binary = true + line.startsWith("@@") -> { + inHunk = true + lines.add(line) + } + inHunk && line.startsWith("+") -> { + added++ + lines.add(line) + } + inHunk && line.startsWith("-") -> { + removed++ + lines.add(line) + } + inHunk && (line.startsWith(" ") || line.startsWith("\\")) -> lines.add(line) + } + } + finish() + return files +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt new file mode 100644 index 00000000..91b4a7ba --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt @@ -0,0 +1,397 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +// Matches MAX_NOTES_BYTES in electron/remote/server.ts. +private const val MAX_NOTES_BYTES = 100 * 1024 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () -> Unit) { + val scope = rememberCoroutineScope() + var projects by remember { mutableStateOf?>(null) } + var loadAttempt by remember { mutableIntStateOf(0) } + var projectId by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var prompt by rememberSaveable { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + // Same default title as the phone web UI: the start of the prompt. + val title = name.trim().ifEmpty { prompt.trim().replace(Regex("\\s+"), " ").take(80) } + + fun failed(e: ApiException, suffix: String = "") { + // The client has already dropped a rejected paired token. + if (e.status == 401) onNeedsPairing() else error = e.message + suffix + } + + LaunchedEffect(loadAttempt) { + error = null + try { + val list = client.fetchProjects() + projects = list + if (list.none { it.id == projectId }) projectId = list.firstOrNull()?.id.orEmpty() + } catch (e: ApiException) { + projects = emptyList() + failed(e) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + navigationIcon = { + TextButton(onClick = onDone, enabled = !busy) { + Text("Cancel", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Text("New task", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge) + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "PROJECT", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.extra.textMuted, + ) + val loaded = projects + when { + loaded == null -> Text("Loading projects…", color = AppTheme.extra.textMuted) + loaded.isEmpty() -> { + Text( + "No projects available. Add one in Parallel Code on your computer.", + color = AppTheme.extra.textMuted, + ) + TextButton(onClick = { loadAttempt++ }) { Text("Retry") } + } + else -> loaded.forEach { project -> + val isSelected = project.id == projectId + Surface( + shape = MaterialTheme.shapes.large, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) else MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else AppTheme.extra.border), + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .selectable( + selected = isSelected, + enabled = !busy, + role = Role.RadioButton, + onClick = { projectId = project.id }, + ), + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = isSelected, + onClick = null, + enabled = !busy, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = AppTheme.extra.textMuted, + ), + ) + Column(Modifier.padding(start = 10.dp)) { + Text( + project.name, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = AppTheme.extra.textPrimary, + ) + Text( + project.agentName?.let { "Runs with $it" } ?: "Runs with your default agent", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } + } + } + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp), + label = { Text("What should the agent work on?") }, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Task name (optional)") }, + placeholder = { Text(title, color = AppTheme.extra.textSubtle) }, + singleLine = true, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + val createInteractionSource = remember { MutableInteractionSource() } + val isCreatePressed by createInteractionSource.collectIsPressedAsState() + val createScale by animateFloatAsState( + targetValue = if (isCreatePressed) 0.96f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "createButtonScale", + ) + Button( + interactionSource = createInteractionSource, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = createScale + scaleY = createScale + }, + enabled = !busy && projectId.isNotEmpty() && prompt.isNotBlank(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + busy = true + error = null + scope.launch { + try { + client.createTask(projectId, title, prompt.trim()) + onDone() + } catch (e: ApiException) { + failed( + e, + " Your draft is kept. If the connection dropped, check the task list before retrying.", + ) + } finally { + busy = false + } + } + }, + ) { Text(if (busy) "Creating…" else "Create task", fontWeight = FontWeight.Bold) } + } + } +} + +/** The task's notes panel from the desktop; editable once the phone is paired. */ +@Composable +fun NotesPane(taskId: String, canEdit: Boolean, client: RemoteClient, modifier: Modifier) { + val scope = rememberCoroutineScope() + var notes by rememberSaveable(taskId) { mutableStateOf("") } + var dirty by rememberSaveable(taskId) { mutableStateOf(false) } + var loaded by remember(taskId) { mutableStateOf(false) } + var loadAttempt by remember { mutableIntStateOf(0) } + var saving by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(taskId, loadAttempt) { + error = null + try { + val remote = client.fetchNotes(taskId) + // Keep unsaved edits rather than overwrite them with the desktop's copy. + if (!dirty) notes = remote + loaded = true + } catch (e: ApiException) { + error = e.message + } + } + + Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = notes, + onValueChange = { + notes = it + dirty = true + saved = false + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + placeholder = { Text(if (loaded) "No notes yet" else "Loading notes…", color = AppTheme.extra.textSubtle) }, + readOnly = !canEdit || !loaded, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedContent( + targetState = when { + saving -> "saving" + dirty -> "dirty" + saved -> "saved" + else -> "idle" + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "notesStatusAnimation", + modifier = Modifier.weight(1f), + ) { status -> + Text( + text = when (status) { + "saving" -> "Saving…" + "dirty" -> "Unsaved changes" + "saved" -> "✓ Saved to your computer" + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = when (status) { + "dirty" -> AppTheme.extra.warningText + "saved" -> AppTheme.extra.success + else -> AppTheme.extra.textMuted + }, + ) + } + if (!loaded) TextButton(onClick = { loadAttempt++ }) { Text("Reload") } + if (canEdit) { + val saveInteractionSource = remember { MutableInteractionSource() } + val isSavePressed by saveInteractionSource.collectIsPressedAsState() + val saveScale by animateFloatAsState( + targetValue = if (isSavePressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "saveButtonScale", + ) + Button( + interactionSource = saveInteractionSource, + modifier = Modifier.graphicsLayer { + scaleX = saveScale + scaleY = saveScale + }, + enabled = loaded && dirty && !saving, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + if (notes.toByteArray().size > MAX_NOTES_BYTES) { + error = "Notes must be 100 KB or less." + return@Button + } + saving = true + error = null + val text = notes + scope.launch { + try { + client.saveNotes(taskId, text) + // Typing during the save keeps the note marked unsaved. + if (notes == text) { + dirty = false + saved = true + } + } catch (e: ApiException) { + error = e.message + } finally { + saving = false + } + } + }, + ) { Text("Save", fontWeight = FontWeight.Bold) } + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt new file mode 100644 index 00000000..0f638eba --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt @@ -0,0 +1,33 @@ +package com.parallelcode.phone + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Retains virtual terminal state and scrollback history for an agent. + * Kept in memory while the app is connected so background tasks can buffer their + * output and allow users to read up when selecting the task. + */ +class TerminalBuffer( + val agentId: String, + val screen: TerminalScreen = TerminalScreen(), +) : TerminalListener { + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + override fun onScrollback(data: ByteArray, cols: Int, rows: Int) { + screen.reset(cols, rows, data) + _version.value++ + } + + fun resize(cols: Int, rows: Int) { + screen.resize(cols, rows) + _version.value++ + } + + override fun onOutput(data: ByteArray) { + screen.feed(data) + _version.value++ + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt new file mode 100644 index 00000000..a762251a --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt @@ -0,0 +1,407 @@ +package com.parallelcode.phone + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction + +/** A run of characters sharing one [CellStyle]-encoded style. */ +data class StyledSpan(val text: String, val style: Long) + +/** + * VT100 screen for an agent's PTY stream. Agent TUIs redraw with cursor movement, so stripping + * escape codes would garble them; this keeps a character grid and applies the movement, erase, + * scroll and SGR (color and style) sequences they use. + */ +class TerminalScreen(cols: Int = 80, rows: Int = 24) { + var cols = cols + private set + var rows = rows + private set + + /** Set by the program (`CSI ? 2004 h`); multi-line replies must then be wrapped as a paste. */ + var bracketedPaste = false + private set + + private class Line(cols: Int, style: Long) { + val chars = CharArray(cols) { ' ' } + val styles = LongArray(cols) { style } + + fun fill(from: Int, to: Int, style: Long) { + chars.fill(' ', from, to) + styles.fill(style, from, to) + } + } + + private var grid = blankGrid() + private var savedMainGrid: Array? = null + // Lines that scrolled off never change again, so they are kept already split into style runs: + // rendering then only has to split the screen's rows. + private val history = ArrayDeque>() + private var row = 0 + private var col = 0 + private var wrapPending = false + private var scrollTop = 0 + private var scrollBottom = rows - 1 + private var savedRow = 0 + private var savedCol = 0 + private var style = CellStyle.DEFAULT + private var savedStyle = CellStyle.DEFAULT + + private var state = State.GROUND + private val sequence = StringBuilder() + private val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + private var undecoded = ByteArray(0) + + private enum class State { GROUND, ESCAPE, ESCAPE_CHARSET, CSI, STRING, STRING_ESCAPE } + + /** Replace everything with a server scrollback snapshot. */ + fun reset(cols: Int, rows: Int, data: ByteArray) { + this.cols = cols.coerceIn(1, 500) + this.rows = rows.coerceIn(1, 300) + style = CellStyle.DEFAULT + savedStyle = CellStyle.DEFAULT + grid = blankGrid() + savedMainGrid = null + history.clear() + row = 0 + col = 0 + wrapPending = false + scrollTop = 0 + scrollBottom = this.rows - 1 + savedRow = 0 + savedCol = 0 + bracketedPaste = false + state = State.GROUND + sequence.clear() + decoder.reset() + undecoded = ByteArray(0) + feed(data) + } + + /** + * Change the screen size in place, as a terminal window does when the PTY is resized for this + * phone. When rows shrink, lines above the cursor scroll into history so the cursor stays put. + */ + fun resize(cols: Int, rows: Int) { + val newCols = cols.coerceIn(1, 500) + val newRows = rows.coerceIn(1, 300) + if (newCols == this.cols && newRows == this.rows) return + val shift = (row + 1 - newRows).coerceAtLeast(0) + if (savedMainGrid == null) { + for (r in 0 until shift) { + history.addLast(spans(grid[r])) + if (history.size > MAX_HISTORY) history.removeFirst() + } + } + fun refit(old: Array, from: Int) = Array(newRows) { r -> + val line = Line(newCols, CellStyle.DEFAULT) + old.getOrNull(from + r)?.let { src -> + val n = minOf(newCols, src.chars.size) + src.chars.copyInto(line.chars, 0, 0, n) + src.styles.copyInto(line.styles, 0, 0, n) + } + line + } + grid = refit(grid, shift) + savedMainGrid = savedMainGrid?.let { refit(it, 0) } + this.cols = newCols + this.rows = newRows + scrollTop = 0 + scrollBottom = newRows - 1 + moveTo(row - shift, col) + savedRow = savedRow.coerceIn(0, newRows - 1) + savedCol = savedCol.coerceIn(0, newCols - 1) + } + + fun feed(data: ByteArray) { + // A multi-byte character can be split across chunks; carry the tail to the next call. + val input = ByteBuffer.wrap(undecoded + data) + val out = CharBuffer.allocate(input.remaining() + 1) + decoder.decode(input, out, false) + undecoded = ByteArray(input.remaining()).also { input.get(it) } + out.flip() + while (out.hasRemaining()) process(out.get()) + } + + /** Scrolled-off history followed by the screen, as plain text. */ + fun text(): String = styledLines().joinToString("\n") { line -> line.joinToString("") { it.text } } + + /** History then screen, each line split into style runs, without trailing blank lines. */ + fun styledLines(): List> { + val lines = ArrayList>(history.size + rows) + lines.addAll(history) + grid.mapTo(lines, ::spans) + while (lines.isNotEmpty() && lines.last().isEmpty()) lines.removeAt(lines.lastIndex) + return lines + } + + private fun spans(line: Line): List { + // Trailing blanks only matter when they are painted with a background color. + var end = line.chars.size + while (end > 0 && line.chars[end - 1] == ' ' && CellStyle.bg(line.styles[end - 1]) == CellStyle.DEFAULT_COLOR) end-- + val spans = ArrayList() + var start = 0 + while (start < end) { + var stop = start + 1 + while (stop < end && line.styles[stop] == line.styles[start]) stop++ + spans.add(StyledSpan(String(line.chars, start, stop - start), line.styles[start])) + start = stop + } + return spans + } + + private fun blankGrid() = Array(rows) { Line(cols, CellStyle.DEFAULT) } + + /** Erased and scrolled-in cells take the current background, as in xterm. */ + private fun blankLine() = Line(cols, CellStyle.eraseStyle(style)) + + private fun process(c: Char) { + when (state) { + State.GROUND -> ground(c) + State.ESCAPE -> escape(c) + State.ESCAPE_CHARSET -> state = State.GROUND + State.CSI -> { + if (c in '@'..'~') { + state = State.GROUND + csi(sequence.toString(), c) + } else if (sequence.length < 128) { + sequence.append(c) + } + } + // OSC, DCS and similar strings end with BEL or ESC \; nothing in them is shown. + State.STRING -> when (c) { + '\u0007' -> state = State.GROUND + '\u001b' -> state = State.STRING_ESCAPE + } + State.STRING_ESCAPE -> state = if (c == '\\') State.GROUND else State.STRING + } + } + + private fun ground(c: Char) { + when (c) { + '\u001b' -> state = State.ESCAPE + '\r' -> carriageReturn() + '\n', '\u000b', '\u000c' -> lineFeed() + '\b' -> { + wrapPending = false + if (col > 0) col-- + } + '\t' -> { + wrapPending = false + col = minOf(cols - 1, (col / 8 + 1) * 8) + } + else -> if (c >= ' ' && c != '\u007f') put(c) + } + } + + private fun escape(c: Char) { + state = State.GROUND + when (c) { + '[' -> { + sequence.clear() + state = State.CSI + } + ']', 'P', '_', '^', 'X' -> state = State.STRING + '(', ')', '*', '+', '#', '%' -> state = State.ESCAPE_CHARSET + '7' -> saveCursor() + '8' -> restoreCursor() + 'D' -> lineFeed() + 'E' -> { + carriageReturn() + lineFeed() + } + 'M' -> reverseIndex() + 'c' -> reset(cols, rows, ByteArray(0)) + } + } + + private fun put(c: Char) { + if (wrapPending) { + carriageReturn() + lineFeed() + } + grid[row].chars[col] = c + grid[row].styles[col] = style + if (col == cols - 1) wrapPending = true else col++ + } + + private fun carriageReturn() { + col = 0 + wrapPending = false + } + + private fun lineFeed() { + wrapPending = false + if (row == scrollBottom) scrollUp(1) else if (row < rows - 1) row++ + } + + private fun reverseIndex() { + wrapPending = false + if (row == scrollTop) scrollDown(1) else if (row > 0) row-- + } + + private fun scrollUp(n: Int) { + repeat(n.coerceAtMost(scrollBottom - scrollTop + 1)) { + // Lines scrolling off the top row move into history so agent TUIs with pinned status bars can be scrolled. + if (scrollTop == 0 && savedMainGrid == null) { + history.addLast(spans(grid[0])) + if (history.size > MAX_HISTORY) history.removeFirst() + } + for (r in scrollTop until scrollBottom) grid[r] = grid[r + 1] + grid[scrollBottom] = blankLine() + } + } + + private fun scrollDown(n: Int) { + repeat(n.coerceAtMost(scrollBottom - scrollTop + 1)) { + for (r in scrollBottom downTo scrollTop + 1) grid[r] = grid[r - 1] + grid[scrollTop] = blankLine() + } + } + + private fun saveCursor() { + savedRow = row + savedCol = col + savedStyle = style + } + + private fun restoreCursor() { + moveTo(savedRow, savedCol) + style = savedStyle + } + + private fun moveTo(r: Int, c: Int) { + row = r.coerceIn(0, rows - 1) + col = c.coerceIn(0, cols - 1) + wrapPending = false + } + + private fun csi(body: String, final: Char) { + if (final == 'm' && !body.startsWith('?') && !body.startsWith('>')) { + style = CellStyle.applySgr(style, body) + return + } + val private = body.startsWith('?') + val params = body.trimStart('?', '>', '<', '=') + .takeWhile { it.isDigit() || it == ';' } + .split(';') + .map { it.toIntOrNull() ?: 0 } + fun arg(i: Int, default: Int = 1) = params.getOrNull(i)?.takeIf { it != 0 } ?: default + fun rawArg(i: Int) = params.getOrNull(i) ?: 0 + + if (private) { + if (final == 'h' || final == 'l') params.forEach { setMode(it, final == 'h') } + return + } + val line = grid[row] + val erase = CellStyle.eraseStyle(style) + when (final) { + 'A' -> moveTo(maxOf(row - arg(0), if (row >= scrollTop) scrollTop else 0), col) + 'B' -> moveTo(minOf(row + arg(0), if (row <= scrollBottom) scrollBottom else rows - 1), col) + 'C' -> moveTo(row, col + arg(0)) + 'D' -> moveTo(row, col - arg(0)) + 'E' -> moveTo(row + arg(0), 0) + 'F' -> moveTo(row - arg(0), 0) + 'G', '`' -> moveTo(row, arg(0) - 1) + 'd' -> moveTo(arg(0) - 1, col) + 'H', 'f' -> moveTo(arg(0) - 1, arg(1) - 1) + 'J' -> eraseInDisplay(rawArg(0)) + 'K' -> eraseInLine(rawArg(0)) + 'L' -> if (row in scrollTop..scrollBottom) insertLines(arg(0)) + 'M' -> if (row in scrollTop..scrollBottom) deleteLines(arg(0)) + '@' -> { + val n = arg(0).coerceAtMost(cols - col) + for (c in cols - 1 downTo col + n) { + line.chars[c] = line.chars[c - n] + line.styles[c] = line.styles[c - n] + } + line.fill(col, col + n, erase) + } + 'P' -> { + val n = arg(0).coerceAtMost(cols - col) + for (c in col until cols - n) { + line.chars[c] = line.chars[c + n] + line.styles[c] = line.styles[c + n] + } + line.fill(cols - n, cols, erase) + } + 'X' -> line.fill(col, minOf(cols, col + arg(0)), erase) + 'S' -> scrollUp(arg(0)) + 'T' -> scrollDown(arg(0)) + 'r' -> { + val top = arg(0) - 1 + val bottom = arg(1, rows) - 1 + if (top < bottom && bottom < rows) { + scrollTop = top + scrollBottom = bottom + moveTo(0, 0) + } + } + 's' -> saveCursor() + 'u' -> restoreCursor() + } + } + + private fun setMode(mode: Int, on: Boolean) { + when (mode) { + 2004 -> bracketedPaste = on + 47, 1047, 1049 -> { + if (on && savedMainGrid == null) { + if (mode == 1049) saveCursor() + savedMainGrid = grid + grid = blankGrid() + } else if (!on) { + savedMainGrid?.let { grid = it } + savedMainGrid = null + if (mode == 1049) restoreCursor() + } + } + } + } + + private fun eraseInDisplay(mode: Int) { + when (mode) { + 0 -> { + eraseInLine(0) + for (r in row + 1 until rows) grid[r] = blankLine() + } + 1 -> { + eraseInLine(1) + for (r in 0 until row) grid[r] = blankLine() + } + 2 -> for (r in 0 until rows) grid[r] = blankLine() + 3 -> history.clear() + } + } + + private fun eraseInLine(mode: Int) { + val erase = CellStyle.eraseStyle(style) + when (mode) { + 0 -> grid[row].fill(col, cols, erase) + 1 -> grid[row].fill(0, col + 1, erase) + 2 -> grid[row].fill(0, cols, erase) + } + } + + private fun insertLines(n: Int) { + repeat(n.coerceAtMost(scrollBottom - row + 1)) { + for (r in scrollBottom downTo row + 1) grid[r] = grid[r - 1] + grid[row] = blankLine() + } + col = 0 + } + + private fun deleteLines(n: Int) { + repeat(n.coerceAtMost(scrollBottom - row + 1)) { + for (r in row until scrollBottom) grid[r] = grid[r + 1] + grid[scrollBottom] = blankLine() + } + col = 0 + } + + private companion object { + const val MAX_HISTORY = 5000 + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt new file mode 100644 index 00000000..13cb2611 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt @@ -0,0 +1,197 @@ +package com.parallelcode.phone + +import androidx.compose.ui.graphics.Color + +/** + * A cell's SGR state packed into a Long so the screen stores one primitive per cell. + * Bits 0–25: foreground, 26–51: background, 52+: flags. A color is [DEFAULT_COLOR], a palette + * index (0–255), or [RGB_FLAG] plus a 24-bit RGB value. + */ +object CellStyle { + const val DEFAULT_COLOR = 0x3FFFFFF + const val RGB_FLAG = 0x1000000 + private const val COLOR_BITS = 26 + private const val FLAG_SHIFT = 52 + + const val BOLD = 1 + const val DIM = 2 + const val ITALIC = 4 + const val UNDERLINE = 8 + const val INVERSE = 16 + const val STRIKE = 32 + const val HIDDEN = 64 + + const val DEFAULT: Long = DEFAULT_COLOR.toLong() or (DEFAULT_COLOR.toLong() shl COLOR_BITS) + + fun fg(style: Long): Int = (style and DEFAULT_COLOR.toLong()).toInt() + fun bg(style: Long): Int = ((style shr COLOR_BITS) and DEFAULT_COLOR.toLong()).toInt() + fun flags(style: Long): Int = (style ushr FLAG_SHIFT).toInt() + + fun of(fg: Int, bg: Int, flags: Int): Long = + fg.toLong() or (bg.toLong() shl COLOR_BITS) or (flags.toLong() shl FLAG_SHIFT) + + /** Erased cells keep only the background, as xterm does. */ + fun eraseStyle(style: Long): Long = of(DEFAULT_COLOR, bg(style), 0) + + /** Apply one `CSI … m` body, in both `;` and `:` sub-parameter forms. */ + fun applySgr(style: Long, body: String): Long { + var fg = fg(style) + var bg = bg(style) + var flags = flags(style) + val groups = body.split(';') + var i = 0 + while (i < groups.size) { + val group = groups[i] + if (':' in group) { + val sub = group.split(':').map { it.toIntOrNull() } + when (sub[0]) { + 38 -> extendedColor(sub.drop(1))?.let { fg = it } + 48 -> extendedColor(sub.drop(1))?.let { bg = it } + 4 -> flags = if (sub.getOrNull(1) == 0) flags and UNDERLINE.inv() else flags or UNDERLINE + } + i++ + continue + } + val code = group.toIntOrNull() ?: 0 + if (code == 38 || code == 48) { + // `38;5;n` or `38;2;r;g;b` + val mode = groups.getOrNull(i + 1)?.toIntOrNull() + val argCount = when (mode) { + 5 -> 1 + 2 -> 3 + else -> 0 + } + val args = groups.subList(minOf(i + 1, groups.size), minOf(i + 2 + argCount, groups.size)) + .map { it.toIntOrNull() } + extendedColor(args)?.let { if (code == 38) fg = it else bg = it } + i += 2 + argCount + continue + } + when (code) { + 0 -> { + fg = DEFAULT_COLOR + bg = DEFAULT_COLOR + flags = 0 + } + 1 -> flags = flags or BOLD + 2 -> flags = flags or DIM + 3 -> flags = flags or ITALIC + 4 -> flags = flags or UNDERLINE + 7 -> flags = flags or INVERSE + 8 -> flags = flags or HIDDEN + 9 -> flags = flags or STRIKE + 21, 22 -> flags = flags and (BOLD or DIM).inv() + 23 -> flags = flags and ITALIC.inv() + 24 -> flags = flags and UNDERLINE.inv() + 27 -> flags = flags and INVERSE.inv() + 28 -> flags = flags and HIDDEN.inv() + 29 -> flags = flags and STRIKE.inv() + in 30..37 -> fg = code - 30 + 39 -> fg = DEFAULT_COLOR + in 40..47 -> bg = code - 40 + 49 -> bg = DEFAULT_COLOR + in 90..97 -> fg = code - 90 + 8 + in 100..107 -> bg = code - 100 + 8 + } + i++ + } + return of(fg, bg, flags) + } + + /** `[5, n]` or `[2, r, g, b]`; the colon form may put a color-space id before r, g, b. */ + private fun extendedColor(args: List): Int? = when (args.getOrNull(0)) { + 5 -> args.getOrNull(1)?.takeIf { it in 0..255 } + 2 -> { + val rgb = (if (args.size >= 5) args.takeLast(3) else args.drop(1)) + .filterNotNull() + .filter { it in 0..255 } + if (rgb.size == 3) RGB_FLAG or (rgb[0] shl 16) or (rgb[1] shl 8) or rgb[2] else null + } + else -> null + } +} + +/** Colors for one span, as ARGB. `background` is null where the terminal background shows. */ +data class ResolvedStyle( + val foreground: Int, + val background: Int?, + val bold: Boolean, + val italic: Boolean, + val underline: Boolean, + val strike: Boolean, +) + +/** + * A terminal's colors: the default text color, its background, and the 16 ANSI + * colors. The background is the look's own panel color, and the ANSI set is the + * one the desktop pairs with that look (see the generated [TerminalTheme]s). + */ +class TerminalPalette( + val foreground: Int, + val background: Int, + private val ansi: IntArray, +) { + fun resolve(style: Long): ResolvedStyle { + val flags = CellStyle.flags(style) + val bold = flags and CellStyle.BOLD != 0 + var fg = CellStyle.fg(style).let { c -> + when { + c == CellStyle.DEFAULT_COLOR -> foreground + // Like xterm.js, bold text in the first eight colors uses their bright variant. + bold && c < 8 -> ansi[c + 8] + else -> color(c) + } + } + var bg = CellStyle.bg(style).takeIf { it != CellStyle.DEFAULT_COLOR }?.let(::color) + if (flags and CellStyle.INVERSE != 0) { + val swapped = bg ?: background + bg = fg + fg = swapped + } + if (flags and CellStyle.DIM != 0) fg = (fg and 0xFFFFFF) or (0x80 shl 24) + if (flags and CellStyle.HIDDEN != 0) fg = bg ?: background + return ResolvedStyle( + foreground = fg, + background = bg, + bold = bold, + italic = flags and CellStyle.ITALIC != 0, + underline = flags and CellStyle.UNDERLINE != 0, + strike = flags and CellStyle.STRIKE != 0, + ) + } + + private fun color(c: Int): Int = OPAQUE or when { + c and CellStyle.RGB_FLAG != 0 -> c and 0xFFFFFF + c < 16 -> ansi[c] + c < 232 -> { + val i = c - 16 + (CUBE[i / 36] shl 16) or (CUBE[i / 6 % 6] shl 8) or CUBE[i % 6] + } + else -> (8 + (c - 232) * 10).let { (it shl 16) or (it shl 8) or it } + } + + companion object { + private const val OPAQUE = 0xFF shl 24 + private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255) + + /** + * The terminal palette for [palette], pairing its generated ANSI set with + * its own panel background, which is what the desktop does per look. + */ + fun forLook(palette: LookPalette): TerminalPalette { + val theme = ALL_TERMINAL_THEMES[palette.terminalThemeId] ?: error("no terminal theme ${palette.terminalThemeId}") + return TerminalPalette( + foreground = OPAQUE or theme.foreground, + background = OPAQUE or palette.panelBg.toArgbInt(), + ansi = IntArray(16) { OPAQUE or theme.ansi[it] }, + ) + } + + /** A [Color]'s 0xAARRGGBB int; the terminal palettes store colors this way. */ + private fun Color.toArgbInt(): Int = + ((alpha * 255).toInt() shl 24) or + ((red * 255).toInt() shl 16) or + ((green * 255).toInt() shl 8) or + (blue * 255).toInt() + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Theme.kt b/android/app/src/main/java/com/parallelcode/phone/Theme.kt new file mode 100644 index 00000000..c5cfdfad --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Theme.kt @@ -0,0 +1,246 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The phone's theme, driven by a [LookPalette] generated from the desktop app + * (see LookPalettes.kt). Pick a palette with [ParallelCodeTheme] and read the + * colors that Material3 has no slot for through [AppTheme.extra]. + * + * Derived tints (the "needs you" attention wash, warning banners, containers) + * are blended from the palette here instead of being hand-picked per theme, so a + * new preset gets correct tints for free. + */ +@Immutable +data class ExtendedColors( + /** The active preset's own light/dark choice, which can differ from the phone's. */ + val dark: Boolean, + val border: Color, + val borderSubtle: Color, + val borderFocus: Color, + val inputBg: Color, + val bgHover: Color, + val bgSelected: Color, + val cardBg: Color, + val cardBgAttention: Color, + val attentionBorder: Color, + val attentionBg: Color, + val warningBannerBg: Color, + val warningText: Color, + val link: Color, + val success: Color, + val error: Color, + val review: Color, + val info: Color, + val textPrimary: Color, + val textMuted: Color, + val textSubtle: Color, + val diffAddBg: Color, + val diffRemoveBg: Color, + /** Corner radius for cards and panels, from the preset's `--island-radius`. */ + val cardRadius: Dp, +) + +/** + * Composites [top] over [base] at [alpha] (0..1), keeping [base]'s alpha. This + * is the phone's stand-in for CSS `color-mix(in srgb, …)`, which is how the + * desktop derives its attention and warning tints from one status hue. + */ +fun blendOver(base: Color, top: Color, alpha: Float): Color { + val a = alpha.coerceIn(0f, 1f) + val inv = 1f - a + return Color( + red = base.red * inv + top.red * a, + green = base.green * inv + top.green * a, + blue = base.blue * inv + top.blue * a, + alpha = base.alpha, + ) +} + +/** [top] at [alpha] opacity, for washes that sit on an unknown surface. */ +fun withAlpha(top: Color, alpha: Float): Color = top.copy(alpha = alpha.coerceIn(0f, 1f)) + +/** + * The alphas the attention and warning tints use. Dark themes need a stronger + * wash because the accent is light on a dark card; light themes need less, or the + * card stops reading as neutral. These reproduce the Obsidian values the app + * shipped with. + */ +private const val ATTENTION_CARD_ALPHA = 0.08f +private const val ATTENTION_BORDER_ALPHA = 0.5f +private const val ATTENTION_WASH_ALPHA = 0.2f +private const val WARNING_BANNER_ALPHA = 0.12f + +/** Tints derived from a palette, for the slots Material3 does not cover. */ +fun extendedColorsFor(palette: LookPalette): ExtendedColors = + ExtendedColors( + dark = palette.dark, + border = palette.border, + borderSubtle = palette.borderSubtle, + borderFocus = palette.borderFocus, + inputBg = palette.bgInput, + bgHover = palette.bgHover, + bgSelected = palette.bgSelected, + cardBg = palette.islandBg, + cardBgAttention = blendOver(palette.islandBg, palette.warning, ATTENTION_CARD_ALPHA), + attentionBorder = blendOver(palette.islandBg, palette.warning, ATTENTION_BORDER_ALPHA), + attentionBg = withAlpha(palette.warning, ATTENTION_WASH_ALPHA), + warningBannerBg = blendOver(palette.islandBg, palette.warning, WARNING_BANNER_ALPHA), + warningText = palette.warning, + link = palette.link, + success = palette.success, + error = palette.error, + review = palette.review, + info = palette.info, + textPrimary = palette.fg, + textMuted = palette.fgMuted, + textSubtle = palette.fgSubtle, + diffAddBg = palette.diffAddBg, + diffRemoveBg = palette.diffRemoveBg, + cardRadius = palette.radiusIsland, + ) + +/** + * Material3's scheme, filled from the palette. The desktop has no Material roles, + * so each one takes the closest variable: surfaces follow the `--bg` family, + * status slots take the matching status hue, and the primary slot is the accent + * the desktop uses for the active action. + */ +fun colorSchemeFor(palette: LookPalette): ColorScheme { + val extended = extendedColorsFor(palette) + // A quiet wash of the accent, as the desktop's hover tint. + val accentContainer = blendOver(palette.islandBg, palette.accent, 0.16f) + val statusContainer = blendOver(palette.islandBg, palette.error, WARNING_BANNER_ALPHA) + val scrim = withAlpha(palette.fg, 0.32f) + return if (palette.dark) { + darkColorScheme( + primary = palette.accent, + onPrimary = palette.accentText, + primaryContainer = accentContainer, + onPrimaryContainer = palette.accentHover, + secondary = palette.info, + onSecondary = palette.bg, + secondaryContainer = palette.bgHover, + onSecondaryContainer = palette.fg, + tertiary = palette.review, + onTertiary = palette.bg, + background = palette.bg, + onBackground = palette.fg, + surface = palette.panelBg, + onSurface = palette.fg, + surfaceVariant = palette.bgElevated, + onSurfaceVariant = palette.fgMuted, + surfaceContainer = palette.containerBg, + surfaceContainerHigh = palette.bgElevated, + outline = extended.border, + outlineVariant = extended.borderSubtle, + error = palette.error, + onError = palette.accentText, + errorContainer = statusContainer, + onErrorContainer = palette.error, + scrim = scrim, + ) + } else { + lightColorScheme( + primary = palette.accent, + onPrimary = palette.accentText, + primaryContainer = accentContainer, + onPrimaryContainer = palette.accentHover, + secondary = palette.info, + onSecondary = palette.bg, + secondaryContainer = palette.bgHover, + onSecondaryContainer = palette.fg, + tertiary = palette.review, + onTertiary = palette.bg, + background = palette.bg, + onBackground = palette.fg, + surface = palette.panelBg, + onSurface = palette.fg, + surfaceVariant = palette.bgElevated, + onSurfaceVariant = palette.fgMuted, + surfaceContainer = palette.containerBg, + surfaceContainerHigh = palette.bgElevated, + outline = extended.border, + outlineVariant = extended.borderSubtle, + error = palette.error, + onError = palette.accentText, + errorContainer = statusContainer, + onErrorContainer = palette.error, + scrim = scrim, + ) + } +} + +/** The preset's corner radius scale, mapped onto Material3's shape slots. */ +fun shapesFor(palette: LookPalette): Shapes = + Shapes( + extraSmall = RoundedCornerShape(palette.radiusXs), + small = RoundedCornerShape(palette.radiusSm), + medium = RoundedCornerShape(palette.radiusMd), + large = RoundedCornerShape(palette.radiusLg), + extraLarge = RoundedCornerShape(palette.radiusLg), + ) + +private val LocalExtendedColors = staticCompositionLocalOf { extendedColorsFor(LookPresets.defaultForDark()) } +private val LocalLookPalette = staticCompositionLocalOf { LookPresets.defaultForDark() } + +/** + * Applies [palette] to [content]. + * + * Callers resolve the palette from the appearance mode and the per-tone preset + * (see [LookPresets]) rather than passing a bare light/dark flag, so a preset + * that differs from the phone's own light/dark setting still reads correctly. + */ +@Composable +fun ParallelCodeTheme( + palette: LookPalette, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalExtendedColors provides extendedColorsFor(palette), + LocalLookPalette provides palette, + ) { + MaterialTheme( + colorScheme = colorSchemeFor(palette), + shapes = shapesFor(palette), + content = content, + ) + } +} + +/** + * Resolves the palette to draw with: the preset saved for the tone the phone is + * actually in, so a light preset is never drawn in dark mode. + */ +@Composable +fun resolveLookPalette( + darkPresetId: String?, + lightPresetId: String?, + darkTheme: Boolean = isSystemInDarkTheme(), +): LookPalette = LookPresets.forTone(darkTheme, if (darkTheme) darkPresetId else lightPresetId) + +object AppTheme { + /** Colors Material3 has no slot for, tinted for the active preset. */ + val extra: ExtendedColors + @Composable @ReadOnlyComposable + get() = LocalExtendedColors.current + + /** The active preset, for previews and anything that needs its id or radii. */ + val palette: LookPalette + @Composable @ReadOnlyComposable + get() = LocalLookPalette.current +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Usage.kt b/android/app/src/main/java/com/parallelcode/phone/Usage.kt new file mode 100644 index 00000000..d22dd926 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Usage.kt @@ -0,0 +1,258 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.roundToInt + +// Mirrors UsageState in electron/ipc/shared-types.ts and the helpers in src/components/usage-format.ts. + +data class UsageWindow(val usedPercent: Double, val resetsAt: Long?) { + val remainingPercent: Int + get() = maxOf(0, (100 - usedPercent).roundToInt()) + val warn: Boolean + get() = usedPercent >= USAGE_WARN_PERCENT +} + +data class ProviderUsage( + val label: String, + val fiveHour: UsageWindow?, + val sevenDay: UsageWindow?, + /** `error` keeps the last snapshot but marks it stale. */ + val status: String, + val error: String?, +) { + val hasSnapshot: Boolean + get() = fiveHour != null || sevenDay != null +} + +/** Past this share of a window, the meter turns amber. */ +private const val USAGE_WARN_PERCENT = 80 + +private val PROVIDERS = listOf( + "claude" to "Claude", + "codex" to "Codex", + "antigravity" to "Antigravity", +) + +/** + * Providers the desktop status bar would show: those with a snapshot, plus those whose refresh + * failed so the reader sees why the meter stopped moving. + */ +fun parseUsage(json: JSONObject): List { + val result = mutableListOf() + val seen = mutableSetOf() + + for ((key, label) in PROVIDERS) { + val p = json.optJSONObject(key) ?: continue + seen.add(key) + val usage = ProviderUsage( + label = label, + fiveHour = p.optJSONObject("fiveHour")?.let(::parseWindow), + sevenDay = p.optJSONObject("sevenDay")?.let(::parseWindow), + status = p.optString("status"), + error = if (p.isNull("error")) null else p.optString("error"), + ) + if (usage.hasSnapshot || usage.status == "error") { + result.add(usage) + } + } + + for (key in json.keys()) { + if (key in seen) continue + val p = json.optJSONObject(key) ?: continue + val label = when (key.lowercase(Locale.ROOT)) { + "antigravity", "agy" -> "Antigravity" + else -> key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + val usage = ProviderUsage( + label = label, + fiveHour = p.optJSONObject("fiveHour")?.let(::parseWindow), + sevenDay = p.optJSONObject("sevenDay")?.let(::parseWindow), + status = p.optString("status"), + error = if (p.isNull("error")) null else p.optString("error"), + ) + if (usage.hasSnapshot || usage.status == "error") { + result.add(usage) + } + } + + return result +} + +private fun parseWindow(w: JSONObject) = UsageWindow( + usedPercent = w.optDouble("usedPercent", 0.0), + resetsAt = if (w.isNull("resetsAt")) null else w.optLong("resetsAt").takeIf { it > 0 }, +) + +/** "resets 14:30" today, "resets Thu 09:00" otherwise, "reset due" once passed, "" when unknown. */ +fun formatReset( + resetsAt: Long?, + now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + if (resetsAt == null) return "" + if (resetsAt <= now) return "reset due" + val at = Instant.ofEpochMilli(resetsAt).atZone(zone) + val time = at.format(DateTimeFormatter.ofPattern("HH:mm", locale)) + if (at.toLocalDate() == Instant.ofEpochMilli(now).atZone(zone).toLocalDate()) return "resets $time" + return "resets ${at.format(DateTimeFormatter.ofPattern("EEE", locale))} $time" +} + +// The desktop polls the usage endpoints itself; this only re-reads its snapshot. +private const val USAGE_POLL_MS = 60_000L + +/** The desktop status bar's subscription meters. Hidden until the desktop has a snapshot. */ +@Composable +fun UsageStrip(client: RemoteClient, connected: Boolean) { + var usage by remember { mutableStateOf>(emptyList()) } + var refresh by remember { mutableIntStateOf(0) } + // Reads on every (re)connect and tap, then once a minute. + LaunchedEffect(connected, refresh) { + while (connected) { + try { + usage = client.fetchUsage() + } catch (_: ApiException) { + // Keep the last snapshot; the connection status already reports outages. + } + delay(USAGE_POLL_MS) + } + } + if (usage.isEmpty()) return + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .semantics { contentDescription = "Agent usage, tap to refresh" } + .clickable { refresh++ }, + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + usage.forEach { provider -> + val stale = provider.status == "error" + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + provider.label.uppercase(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + provider.fiveHour?.let { UsageMeter("5h", it, stale) } + provider.sevenDay?.let { UsageMeter("7d", it, stale) } + if (!provider.hasSnapshot) { + Text( + "usage unavailable · ${provider.error.orEmpty()}", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textSubtle, + ) + } + } + } + } + } +} + +@Composable +private fun UsageMeter(label: String, window: UsageWindow, stale: Boolean) { + val left = window.remainingPercent + val isCritical = left <= 5 || window.usedPercent >= 95.0 + val fill = when { + stale -> AppTheme.extra.textSubtle + isCritical -> MaterialTheme.colorScheme.error + window.warn -> AppTheme.extra.warningText + else -> MaterialTheme.colorScheme.primary + } + // Fills from empty on first show, then eases between snapshots. + val target = (left / 100f).coerceIn(0f, 1f) + val progress = remember { Animatable(0f) } + LaunchedEffect(target) { progress.animateTo(target, tween(durationMillis = 700, easing = FastOutSlowInEasing)) } + val animatedProgress = progress.value + val textColor = when { + stale -> AppTheme.extra.textSubtle + isCritical -> MaterialTheme.colorScheme.error + window.warn -> AppTheme.extra.warningText + else -> MaterialTheme.colorScheme.onSurface + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + Modifier.width(28.dp), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + Box( + Modifier + .weight(1f) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.background) + .border(BorderStroke(1.dp, AppTheme.extra.border), RoundedCornerShape(3.dp)) + .semantics { contentDescription = "$label window $left% remaining" }, + ) { + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(animatedProgress) + .background(fill), + ) + } + Row(Modifier.padding(start = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "$left% left", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = textColor, + ) + val reset = if (window.remainingPercent == 100) "" else formatReset(window.resetsAt) + if (reset.isNotEmpty()) { + Text( + " · $reset", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..66a93f28 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..fada7815 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..70adfa44 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_100.xml b/android/app/src/main/res/drawable/widget_card_light_100.xml new file mode 100644 index 00000000..5b5c823f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_25.xml b/android/app/src/main/res/drawable/widget_card_light_25.xml new file mode 100644 index 00000000..0aed36e8 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_50.xml b/android/app/src/main/res/drawable/widget_card_light_50.xml new file mode 100644 index 00000000..2cecba24 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_75.xml b/android/app/src/main/res/drawable/widget_card_light_75.xml new file mode 100644 index 00000000..9700c07a --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_100.xml b/android/app/src/main/res/drawable/widget_card_obsidian_100.xml new file mode 100644 index 00000000..69b972aa --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_25.xml b/android/app/src/main/res/drawable/widget_card_obsidian_25.xml new file mode 100644 index 00000000..59e2182d --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_50.xml b/android/app/src/main/res/drawable/widget_card_obsidian_50.xml new file mode 100644 index 00000000..f32eda1e --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_75.xml b/android/app/src/main/res/drawable/widget_card_obsidian_75.xml new file mode 100644 index 00000000..d5e4b3b8 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_100.xml b/android/app/src/main/res/drawable/widget_card_slate_100.xml new file mode 100644 index 00000000..76d462d7 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_25.xml b/android/app/src/main/res/drawable/widget_card_slate_25.xml new file mode 100644 index 00000000..74ea36d1 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_50.xml b/android/app/src/main/res/drawable/widget_card_slate_50.xml new file mode 100644 index 00000000..c23533e9 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_75.xml b/android/app/src/main/res/drawable/widget_card_slate_75.xml new file mode 100644 index 00000000..0c98d71f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/widget_agents.xml b/android/app/src/main/res/layout/widget_agents.xml new file mode 100644 index 00000000..e06b3889 --- /dev/null +++ b/android/app/src/main/res/layout/widget_agents.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..c78bee3b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..0b02e9d4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-v31/themes.xml b/android/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..0d8fccbc --- /dev/null +++ b/android/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..0de82b03 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,34 @@ + + + #FF000000 + + + #FF1E1E1E + #BF1E1E1E + #801E1E1E + #401E1E1E + #FF1E1E1E + #BF1E1E1E + #801E1E1E + #401E1E1E + + #FF3A3F44 + #BF3A3F44 + #803A3F44 + #403A3F44 + #FF3A3F44 + #BF3A3F44 + #803A3F44 + #403A3F44 + + #FFFFFFFF + #BFFFFFFF + #80FFFFFF + #40FFFFFF + #FFE8EAEC + #BFE8EAEC + #80E8EAEC + #40E8EAEC + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..1a94d66d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Agents that need you, and your subscription usage + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..6b6b76ac --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..3b83f044 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/widget_agents_info.xml b/android/app/src/main/res/xml/widget_agents_info.xml new file mode 100644 index 00000000..5d2e3b39 --- /dev/null +++ b/android/app/src/main/res/xml/widget_agents_info.xml @@ -0,0 +1,11 @@ + + diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt new file mode 100644 index 00000000..c380ad59 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt @@ -0,0 +1,50 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgentNotifierTest { + private fun agent(attention: String, running: Boolean = true, collapsed: Boolean = false) = RemoteAgent( + agentId = "a", + taskId = "t", + taskName = "Task", + running = running, + exitCode = null, + lastLine = "", + projectName = null, + agentName = "Claude Code", + attention = attention, + isChat = false, + collapsed = collapsed, + ) + + @Test + fun firstListIsTheBaseline() { + assertTrue(AgentNotifier().update(listOf(agent("needs_input"))).isEmpty()) + } + + @Test + fun announcesNeedsInputErrorsAndFinishing() { + val notifier = AgentNotifier() + notifier.update(listOf(agent("active"))) + assertEquals(listOf(AgentEvent.NEEDS_INPUT), notifier.update(listOf(agent("needs_input"))).map { it.event }) + assertEquals(listOf(AgentEvent.ERROR), notifier.update(listOf(agent("error"))).map { it.event }) + notifier.update(listOf(agent("active"))) + assertEquals(listOf(AgentEvent.FINISHED), notifier.update(listOf(agent("ready"))).map { it.event }) + notifier.update(listOf(agent("active"))) + assertEquals( + listOf(AgentEvent.FINISHED), + notifier.update(listOf(agent("active", running = false))).map { it.event }, + ) + } + + @Test + fun staysQuietForUnchangedMinimizedOrNewAgents() { + val notifier = AgentNotifier() + notifier.update(listOf(agent("needs_input"))) + assertTrue(notifier.update(listOf(agent("needs_input"))).isEmpty()) + assertTrue(notifier.update(listOf(agent("error", collapsed = true))).isEmpty()) + assertTrue(notifier.update(listOf(agent("active"), agent("needs_input").copy(agentId = "b"))).isEmpty()) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt new file mode 100644 index 00000000..dfaad8c1 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt @@ -0,0 +1,82 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgentWidgetTest { + private fun agent(id: String, attention: String, collapsed: Boolean = false) = RemoteAgent( + agentId = id, taskId = id, taskName = id, running = true, exitCode = null, lastLine = "", + projectName = null, agentName = null, attention = attention, isChat = false, collapsed = collapsed, + ) + + @Test + fun countsAgentsThatNeedYouAndWorking() { + val agents = listOf(agent("a", "needs_input"), agent("b", "active"), agent("c", "error", collapsed = true)) + assertEquals("1 need you · 1 working", widgetSummary(agents, emptyList(), connected = true).headline) + assertEquals("1 needs you", widgetSummary(agents.take(1), emptyList(), connected = true).headline) + assertEquals("Not connected", widgetSummary(agents, emptyList(), connected = false).headline) + } + + @Test + fun listsRemainingUsagePerProvider() { + val usage = listOf( + ProviderUsage("Claude", UsageWindow(22.0, null), UsageWindow(87.0, null), "ok", null), + ProviderUsage("Codex", null, null, "ok", null), + ) + assertEquals("Left:\nClaude 5h 78% 7d 13%", widgetSummary(emptyList(), usage, connected = true).usage) + } + + @Test + fun snapsTransparencyToTheOfferedStops() { + assertEquals(100, widgetTransparencyStep(100)) + assertEquals(75, widgetTransparencyStep(80)) + assertEquals(50, widgetTransparencyStep(40)) + assertEquals(25, widgetTransparencyStep(0)) + // Out of range and nonsense values still land on a real stop. + assertEquals(100, widgetTransparencyStep(140)) + assertEquals(25, widgetTransparencyStep(-10)) + } + + @Test + fun everyPaletteAndStopHasItsOwnCard() { + val cards = WIDGET_PALETTES.flatMap { palette -> + WIDGET_TRANSPARENCY_STEPS.map { palette.background(it) } + } + val expected = WIDGET_PALETTES.size * WIDGET_TRANSPARENCY_STEPS.size + assertEquals(expected, cards.size) + assertEquals("each color and stop needs its own shape resource", expected, cards.distinct().size) + } + + @Test + fun looksUpTheCardForAColorAndStop() { + assertEquals(WIDGET_PALETTES[1].background(50), widgetBackground("slate", 50)) + // An unknown color falls back to the first palette rather than failing to draw. + assertEquals(WIDGET_PALETTES.first().background(100), widgetBackground("chartreuse", 100)) + assertEquals(WIDGET_PALETTES.first().background(100), widgetBackground(null, 100)) + // A stop between two values snaps down to a real card. + assertEquals(widgetBackground("light", 75), widgetBackground("light", 80)) + } + + @Test + fun everyPaletteKeepsItsTextReadableOnItsCard() { + // Relative luminance of the card fill, used to pick light or dark text. + fun channel(value: Int): Double { + val c = value / 255.0 + return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4) + } + fun luminance(color: Int): Double = + 0.2126 * channel((color shr 16) and 0xFF) + + 0.7152 * channel((color shr 8) and 0xFF) + + 0.0722 * channel(color and 0xFF) + + WIDGET_PALETTES.forEach { palette -> + val card = luminance(palette.fill) + listOf("title" to palette.title, "headline" to palette.headline, "usage" to palette.usage, "updated" to palette.updated) + .forEach { (name, text) -> + val contrast = (Math.max(card, luminance(text)) + 0.05) / (Math.min(card, luminance(text)) + 0.05) + assertTrue("$name on ${palette.label} is only ${"%.1f".format(contrast)}:1", contrast >= 4.5) + } + } + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt b/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt new file mode 100644 index 00000000..2645654b --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt @@ -0,0 +1,31 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConnectionLinkTest { + @Test + fun parsesTheDesktopQrUrl() { + val link = ConnectionLink.parse("http://192.168.1.20:7777?token=abc") + assertEquals(ConnectionLink("http://192.168.1.20:7777", "abc"), link) + assertEquals("ws://192.168.1.20:7777/ws", link?.webSocketUrl) + } + + @Test + fun keepsOnlyOriginAndDecodesTheToken() { + val link = ConnectionLink.parse(" https://desk.tail1.ts.net/?x=1&token=a%2Bb_c ") + assertEquals(ConnectionLink("https://desk.tail1.ts.net", "a+b_c"), link) + assertEquals("wss://desk.tail1.ts.net/ws", link?.webSocketUrl) + } + + @Test + fun rejectsLinksThatCannotReachADesktop() { + assertNull(ConnectionLink.parse("http://192.168.1.20:7777")) + assertNull(ConnectionLink.parse("http://192.168.1.20:7777?token=")) + assertNull(ConnectionLink.parse("ftp://host?token=abc")) + assertNull(ConnectionLink.parse("javascript:alert(1)?token=abc")) + assertNull(ConnectionLink.parse("not a url")) + assertNull(ConnectionLink.parse("http://host?token=" + "a".repeat(201))) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt new file mode 100644 index 00000000..d1bae088 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt @@ -0,0 +1,81 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CredentialStoreTest { + private val home = ConnectionLink("http://192.168.1.20:7777", "home-token") + private val dev = ConnectionLink("http://192.168.1.20:8777", "dev-token") + + @Test + fun keepsEachComputerWithItsOwnPairing() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.savePairedToken("home-paired") + store.saveLink(dev) + assertEquals(dev, store.link) + assertNull(store.pairedToken) + + store.select(home.baseUrl) + assertEquals(home, store.link) + assertEquals("home-paired", store.pairedToken) + assertEquals(listOf("192.168.1.20:7777", "192.168.1.20:8777"), store.computers.map { it.label }.sorted()) + } + + @Test + fun labelsDefaultToTheAddressAndRenamePersists() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + assertEquals("192.168.1.20:7777", store.computers.single().label) + + store.rename(home.baseUrl, "Home server") + assertEquals("Home server", store.computers.single().label) + + store.rename(home.baseUrl, " ") + assertEquals("192.168.1.20:7777", store.computers.single().label) + } + + @Test + fun rescanningAnAddressKeepsItsLabelButDropsPairing() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.savePairedToken("old") + store.rename(home.baseUrl, "Home server") + store.saveLink(home.copy(token = "new")) + assertEquals("Home server", store.computers.single().label) + assertNull(store.pairedToken) + } + + @Test + fun forgettingTheComputerInUseLeavesNoneSelected() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.saveLink(dev) + store.clear() + assertNull(store.link) + assertEquals(listOf(home.baseUrl), store.computers.map { it.baseUrl }) + } + + @Test + fun rescanningAnAddressReplacesItsTokenAndDropsPairing() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.savePairedToken("old") + store.saveLink(home.copy(token = "new")) + assertEquals(1, store.computers.size) + assertEquals("new", store.link?.token) + assertNull(store.pairedToken) + } + + @Test + fun migratesTheSingleComputerFromEarlierVersions() { + val prefs = FakeSharedPreferences() + prefs.edit().putString("baseUrl", home.baseUrl).putString("token", home.token) + .putString("pairedToken", "kept").commit() + val store = CredentialStore(prefs) + assertEquals(home, store.link) + assertEquals("kept", store.pairedToken) + assertNull(prefs.getString("baseUrl", null)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt b/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt new file mode 100644 index 00000000..f7735e2b --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt @@ -0,0 +1,64 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences + +/** Simple in-memory fake of Android SharedPreferences for unit tests. */ +internal class FakeSharedPreferences : SharedPreferences { + private val map = mutableMapOf() + + override fun getAll(): Map = map + override fun getString(key: String?, defValue: String?): String? = (map[key] as? String) ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: Set?): Set? = + (map[key] as? Set) ?: defValues + override fun getInt(key: String?, defValue: Int): Int = (map[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (map[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (map[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (map[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = map.containsKey(key) + override fun edit(): SharedPreferences.Editor = FakeEditor(map) + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private class FakeEditor(private val backingMap: MutableMap) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clearPending = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putStringSet(key: String?, values: Set?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = values + } + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun remove(key: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = null + } + override fun clear(): SharedPreferences.Editor = apply { + clearPending = true + } + override fun commit(): Boolean { + apply() + return true + } + override fun apply() { + if (clearPending) backingMap.clear() + for ((k, v) in pending) { + if (v == null) backingMap.remove(k) else backingMap[k] = v + } + pending.clear() + clearPending = false + } + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/LookPalettesTest.kt b/android/app/src/test/java/com/parallelcode/phone/LookPalettesTest.kt new file mode 100644 index 00000000..85aeabce --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/LookPalettesTest.kt @@ -0,0 +1,463 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the generated palette file and the mapping from a palette to what the + * app draws. The desktop owns the real values, so these tests check the contract + * between the two rather than restating every color: look ids must match the + * desktop, the tone split must hold, and Obsidian must keep the exact values the + * app shipped with. + */ +class LookPalettesTest { + + @Test + fun exposesEveryDesktopLookPreset() { + val ids = LookPresets.all.map { it.id } + assertEquals( + listOf( + "noir", + "obsidian", + "obsidian-light", + "islands-dark", + "islands-light", + "minimal", + "graphite", + "midnight", + "classic", + "indigo", + "ember", + "glacier", + "zenburnesque", + "catppuccin-mocha", + "workbench", + ), + ids, + ) + } + + @Test + fun idsAreUnique() { + val ids = LookPresets.all.map { it.id } + assertEquals(ids.size, ids.toSet().size) + } + + @Test + fun everyPresetHasLabelAndDescription() { + LookPresets.all.forEach { preset -> + assertTrue("${preset.id} label", preset.label.isNotBlank()) + assertTrue("${preset.id} description", preset.description.isNotBlank()) + } + } + + /** Two light looks exist; the rest are dark, matching the desktop's tones. */ + @Test + fun splitsPresetsByTone() { + assertEquals( + listOf("obsidian-light", "islands-light"), + LookPresets.light.map { it.id }, + ) + assertEquals(LookPresets.all.size - 2, LookPresets.dark.size) + assertFalse(LookPresets.dark.any { !it.dark }) + assertTrue(LookPresets.light.none { it.dark }) + } + + @Test + fun defaultsToObsidianPerTone() { + assertEquals("obsidian", LookPresets.defaultForDark().id) + assertEquals("obsidian-light", LookPresets.defaultForLight().id) + assertEquals("obsidian", LookPresets.defaultFor(dark = true).id) + assertEquals("obsidian-light", LookPresets.defaultFor(dark = false).id) + } + + @Test + fun byIdResolvesKnownPreset() { + assertEquals("ember", LookPresets.byId("ember").id) + } + + @Test + fun byIdFallsBackForUnknownId() { + assertEquals("obsidian", LookPresets.byId("no-such-look").id) + assertEquals("obsidian-light", LookPresets.byId(null, LookPresets.defaultForLight()).id) + } + + @Test + fun isKnownRejectsUnknownAndNull() { + assertTrue(LookPresets.isKnown("workbench")) + assertFalse(LookPresets.isKnown("solarized")) + assertFalse(LookPresets.isKnown(null)) + } + + /** The core invariant: the tone passed in always matches the preset returned. */ + @Test + fun forToneNeverReturnsAPresetOfTheWrongTone() { + LookPresets.all.forEach { preset -> + assertTrue("${preset.id} in dark", LookPresets.forTone(true, preset.id).dark) + assertFalse("${preset.id} in light", LookPresets.forTone(false, preset.id).dark) + } + } + + @Test + fun forToneKeepsMatchingPreset() { + assertEquals("ember", LookPresets.forTone(dark = true, id = "ember").id) + assertEquals("islands-light", LookPresets.forTone(dark = false, id = "islands-light").id) + } + + /** + * Obsidian is the default, so its values are pinned here: these are the exact + * colors the app shipped with, and a generator change must not move them. + */ + @Test + fun obsidianKeepsItsShippedColors() { + val obsidian = LookPresets.byId("obsidian") + assertEquals(Color(0xFF171717), obsidian.bg) + assertEquals(Color(0xFF242424), obsidian.bgElevated) + assertEquals(Color(0xFF262626), obsidian.bgInput) + assertEquals(Color(0xFF1E1E1E), obsidian.islandBg) + assertEquals(Color(0xFF333333), obsidian.border) + assertEquals(Color(0xFF292929), obsidian.borderSubtle) + assertEquals(Color(0xFFEDEDED), obsidian.fg) + assertEquals(Color(0xFFB5B5B5), obsidian.fgMuted) + assertEquals(Color(0xFF919191), obsidian.fgSubtle) + assertEquals(Color(0xFFC4A77D), obsidian.accent) + assertEquals(Color(0xFF1E1B16), obsidian.accentText) + assertEquals(Color(0xFF98C9AE), obsidian.success) + assertEquals(Color(0xFFEAA0AA), obsidian.error) + assertEquals(Color(0xFFF29B70), obsidian.warning) + assertEquals(Color(0xFFC1B0E8), obsidian.review) + } + + @Test + fun obsidianLightKeepsItsShippedColors() { + val light = LookPresets.byId("obsidian-light") + assertEquals(Color(0xFFF4F4F2), light.bg) + assertEquals(Color(0xFF1F1F1F), light.fg) + assertEquals(Color(0xFF8A6433), light.accent) + assertEquals(Color(0xFF2F7D4F), light.success) + assertEquals(Color(0xFFAD4E00), light.warning) + } + + /** Obsidian is the square-edged look; the others keep their own radii. */ + @Test + fun obsidianStaysSquareAndOthersKeepRadius() { + val obsidian = LookPresets.byId("obsidian") + assertEquals(0f, obsidian.radiusIsland.value) + assertEquals(0f, obsidian.radiusMd.value) + assertTrue(LookPresets.byId("noir").radiusIsland.value > 0f) + assertTrue(LookPresets.byId("noir").radiusMd.value > 0f) + } + + /** Flat surfaces cannot show a gradient, so each palette has one solid bg. */ + @Test + fun everyPaletteHasFullyOpaqueSurfaces() { + LookPresets.all.forEach { preset -> + listOf( + "bg" to preset.bg, + "bgElevated" to preset.bgElevated, + "bgInput" to preset.bgInput, + "islandBg" to preset.islandBg, + "panelBg" to preset.panelBg, + "fg" to preset.fg, + "accent" to preset.accent, + ).forEach { (name, color) -> + assertEquals("${preset.id}.$name opaque", 1f, color.alpha, 0.001f) + } + } + } + + /** Diff tints are the one place alpha carries meaning, so they stay translucent. */ + @Test + fun diffTintsStayTranslucent() { + LookPresets.all.forEach { preset -> + assertTrue("${preset.id} diffAddBg", preset.diffAddBg.alpha in 0.05f..0.4f) + assertTrue("${preset.id} diffRemoveBg", preset.diffRemoveBg.alpha in 0.05f..0.4f) + } + } + + @Test + fun presetsAreVisuallyDistinct() { + // Guards against a generator change collapsing two looks into one palette. + // Graphite and Midnight share a background on purpose (Midnight only changes + // the terminal panel), so panelBg is the signal that separates them. + val signatures = + LookPresets.all.map { it.id to listOf(it.bg, it.accent, it.fg, it.islandBg, it.panelBg) } + for (i in signatures.indices) { + for (j in i + 1 until signatures.size) { + assertNotEquals( + "${signatures[i].first} and ${signatures[j].first} look identical", + signatures[i].second, + signatures[j].second, + ) + } + } + } + + @Test + fun blendOverMixesTowardTopColor() { + val base = Color(0xFF000000) + assertEquals(Color(0xFF000000), blendOver(base, Color(0xFFFFFFFF), 0f)) + assertEquals(Color(0xFFFFFFFF), blendOver(base, Color(0xFFFFFFFF), 1f)) + val half = blendOver(Color(0xFF000000), Color(0xFFFFFFFF), 0.5f) + assertEquals(0.5f, half.red, 0.01f) + } + + @Test + fun blendOverKeepsBaseAlpha() { + val blended = blendOver(Color(0x33000000), Color(0xFFFFFFFF), 0.5f) + assertEquals(0x33 / 255f, blended.alpha, 0.01f) + } + + @Test + fun blendOverClampsOutOfRangeAlpha() { + val base = Color(0xFF000000) + assertEquals(base, blendOver(base, Color(0xFFFFFFFF), -1f)) + assertEquals(Color(0xFFFFFFFF), blendOver(base, Color(0xFFFFFFFF), 5f)) + } + + @Test + fun withAlphaSetsOpacityDirectly() { + assertEquals(0.2f, withAlpha(Color(0xFFC4A77D), 0.2f).alpha, 0.001f) + assertEquals(0f, withAlpha(Color(0xFFC4A77D), -1f).alpha, 0.001f) + } + + /** + * The attention tints are blended from the palette rather than hand-picked, so + * Obsidian must land on the values it shipped with. Blending happens in float + * and then quantizes, so channels are compared within one 8-bit step. + */ + @Test + fun obsidianAttentionTintsMatchShippedValues() { + val extra = extendedColorsFor(LookPresets.byId("obsidian")) + assertColorNear(Color(0xFF2F2825), extra.cardBgAttention, "cardBgAttention") + assertColorNear(Color(0xFF885C47), extra.attentionBorder, "attentionBorder") + assertColorNear(Color(0xFF372D28), extra.warningBannerBg, "warningBannerBg") + // The wash is the warning hue itself at 20% alpha, not a blend onto a surface. + assertEquals(0x33 / 255f, extra.attentionBg.alpha, 0.001f) + assertColorNear(Color(0xFFF29B70), extra.attentionBg, "attentionBg") + } + + /** Asserts two colors match to within one 8-bit step per channel. */ + private fun assertColorNear(expected: Color, actual: Color, label: String) { + val tolerance = 1f / 255f + assertEquals("$label red", expected.red, actual.red, tolerance) + assertEquals("$label green", expected.green, actual.green, tolerance) + assertEquals("$label blue", expected.blue, actual.blue, tolerance) + } + + @Test + fun extendedColorsCarryThePaletteThrough() { + LookPresets.all.forEach { preset -> + val extra = extendedColorsFor(preset) + assertEquals(preset.dark, extra.dark) + assertEquals(preset.border, extra.border) + assertEquals(preset.borderSubtle, extra.borderSubtle) + assertEquals(preset.bgInput, extra.inputBg) + assertEquals(preset.islandBg, extra.cardBg) + assertEquals(preset.fg, extra.textPrimary) + assertEquals(preset.fgMuted, extra.textMuted) + assertEquals(preset.fgSubtle, extra.textSubtle) + assertEquals(preset.warning, extra.warningText) + assertEquals(preset.success, extra.success) + assertEquals(preset.review, extra.review) + assertEquals(preset.radiusIsland, extra.cardRadius) + } + } + + @Test + fun colorSchemeFollowsThePaletteTone() { + LookPresets.all.forEach { preset -> + val scheme = colorSchemeFor(preset) + assertEquals("${preset.id} background", preset.bg, scheme.background) + assertEquals("${preset.id} onBackground", preset.fg, scheme.onBackground) + assertEquals("${preset.id} primary", preset.accent, scheme.primary) + assertEquals("${preset.id} onPrimary", preset.accentText, scheme.onPrimary) + assertEquals("${preset.id} error", preset.error, scheme.error) + assertEquals("${preset.id} outline", extendedColorsFor(preset).border, scheme.outline) + assertEquals(1f, scheme.background.alpha, 0.001f) + } + } + + @Test + fun accentContainerIsAQuietWashOfTheAccent() { + val preset = LookPresets.byId("obsidian") + val container = colorSchemeFor(preset).primaryContainer + // Blended toward the island background, so it sits between the two. + assertTrue(container.red > preset.islandBg.red) + assertTrue(container.red < preset.accent.red) + } + + @Test + fun shapesFollowThePaletteRadii() { + LookPresets.all.forEach { preset -> + val shapes = shapesFor(preset) + // Each slot must be a uniform RoundedCornerShape at the palette's radius, + // which is what shapesFor builds. + val slots = + listOf( + Triple("extraSmall", shapes.extraSmall as RoundedCornerShape, preset.radiusXs), + Triple("small", shapes.small as RoundedCornerShape, preset.radiusSm), + Triple("medium", shapes.medium as RoundedCornerShape, preset.radiusMd), + Triple("large", shapes.large as RoundedCornerShape, preset.radiusLg), + Triple("extraLarge", shapes.extraLarge as RoundedCornerShape, preset.radiusLg), + ) + slots.forEach { (slot, corners, radius) -> + val expected = RoundedCornerShape(radius) + assertEquals("${preset.id}.$slot", expected, corners) + } + } + } + + /** + * The widget draws in RemoteViews and cannot read the Compose theme, so its + * Obsidian and Light cards carry their own color literals. Those two mirror the + * matching presets; this keeps a desktop recolor from leaving the widget stale. + */ + @Test + fun widgetCardsMirrorTheObsidianAndLightPresets() { + val obsidianWidget = widgetPalette("obsidian") + val obsidian = LookPresets.byId(LookPresets.PRESET_OBSIDIAN) + assertEquals(obsidian.islandBg.argb(), obsidianWidget.fill) + assertEquals(obsidian.accent.argb(), obsidianWidget.title) + assertEquals(obsidian.fg.argb(), obsidianWidget.headline) + assertEquals(obsidian.fgMuted.argb(), obsidianWidget.usage) + assertEquals(obsidian.fgSubtle.argb(), obsidianWidget.updated) + + val lightWidget = widgetPalette("light") + val light = LookPresets.byId(LookPresets.PRESET_OBSIDIAN_LIGHT) + assertEquals(light.islandBg.argb(), lightWidget.fill) + assertEquals(light.accent.argb(), lightWidget.title) + assertEquals(light.fg.argb(), lightWidget.headline) + assertEquals(light.fgMuted.argb(), lightWidget.usage) + assertEquals(light.fgSubtle.argb(), lightWidget.updated) + } + + /** + * A [Color]'s 0xAARRGGBB int, which is how the widget and the terminal store + * their colors. Written out rather than via `android.graphics.Color`, which is + * not mocked in plain JVM unit tests. + */ + private fun Color.argb(): Int = paletteArgb(this) + +/** + * An opaque 0xAARRGGBB color as a Kotlin [Int]. A hex literal above Int.MAX_VALUE is + * a Long, so terminal and widget colors (always opaque) need this to be compared. + */ +private fun opaque(rgb: Long): Int = (0xFF000000L or rgb).toInt() + +/** The 0xAARRGGBB [Int] form of a [Color], as the widget and terminal store them. */ +private fun paletteArgb(color: Color): Int = + ((color.alpha * 255).toInt() shl 24) or + ((color.red * 255).toInt() shl 16) or + ((color.green * 255).toInt() shl 8) or + (color.blue * 255).toInt() + + /** + * Terminals follow the look, as on the desktop: light presets share one ANSI + * set, the three dark looks that have their own keep them, and the rest fall + * back to the muted dark set. + */ + @Test + fun terminalThemesMatchTheDesktopPairing() { + val expected = mapOf( + "noir" to "noir", + "obsidian" to "obsidian", + "islands-dark" to "islands-dark", + // Every light preset shares one set, as getTerminalTheme does. + "obsidian-light" to "light", + "islands-light" to "light", + // Dark presets with no set of their own fall back. + "graphite" to "noir", + "midnight" to "noir", + "catppuccin-mocha" to "noir", + "workbench" to "noir", + ) + expected.forEach { (presetId, terminalId) -> + assertEquals( + "$presetId terminal theme", + terminalId, + LookPresets.byId(presetId).terminalThemeId, + ) + } + } + + @Test + fun everyTerminalThemeIdResolves() { + LookPresets.all.forEach { preset -> + assertTrue( + "${preset.id} terminal theme ${preset.terminalThemeId}", + ALL_TERMINAL_THEMES.containsKey(preset.terminalThemeId), + ) + } + } + + @Test + fun terminalThemesCarrySixteenAnsiColors() { + ALL_TERMINAL_THEMES.forEach { (id, theme) -> + assertEquals("$id ansi size", 16, theme.ansi.size) + // A terminal color is opaque; a transparent ANSI slot would render as a hole. + theme.ansi.forEach { assertEquals("$id ansi opaque", 0xFF, it ushr 24) } + assertEquals("$id foreground opaque", 0xFF, theme.foreground ushr 24) + } + } + + @Test + fun terminalPalettesTakeTheLooksPanelBackground() { + LookPresets.all.forEach { preset -> + val terminal = TerminalPalette.forLook(preset) + assertEquals( + "${preset.id} terminal background", + paletteArgb(preset.panelBg), + terminal.background, + ) + } + } + + /** + * Obsidian's terminal keeps the exact colors the app shipped with. Checked + * through [TerminalPalette.resolve] so the SGR packing is covered too. + */ + @Test + fun obsidianTerminalKeepsItsShippedAnsi() { + val terminal = TerminalPalette.forLook(LookPresets.byId("obsidian")) + + val default = terminal.resolve(CellStyle.of(CellStyle.DEFAULT_COLOR, CellStyle.DEFAULT_COLOR, 0)) + // No color set: the palette's default foreground. + assertEquals(opaque(0xFFE4E4E4), default.foreground) + // The background is the look's own panel, not a fixed terminal color. + assertEquals(paletteArgb(LookPresets.byId("obsidian").panelBg), terminal.background) + // ANSI slots, plain and bright, in the order TerminalStyle indexes them. + val expectedAnsi = listOf( + 0xFF2E2E2E, 0xFFE08C96, 0xFF98C9AE, 0xFFDFC18E, 0xFF8FB3DC, 0xFFC1B0E8, 0xFF8EC9C9, + 0xFFC9C9C9, 0xFF858585, 0xFFEAA0AA, 0xFFADDCC1, 0xFFEAD3A8, 0xFFA8C5E8, 0xFFD2C4F0, + 0xFFA6DADA, 0xFFEDEDED, + ).map(::opaque) + expectedAnsi.forEachIndexed { slot, expected -> + val style = CellStyle.of(slot, CellStyle.DEFAULT_COLOR, 0) + assertEquals("ansi slot $slot", expected, terminal.resolve(style).foreground) + } + } + + /** Bold text in the first eight colors uses the bright variant, as xterm does. */ + @Test + fun boldAnsiTextUsesTheBrightVariant() { + val terminal = TerminalPalette.forLook(LookPresets.byId("obsidian")) + val boldRed = CellStyle.of(1, CellStyle.DEFAULT_COLOR, CellStyle.BOLD) + assertEquals(opaque(0xFFEAA0AA), terminal.resolve(boldRed).foreground) + } + + @Test + fun midnightDiffersFromGraphiteOnlyInThePanel() { + // Midnight is Graphite with a pure-black terminal panel; the two must not + // be the same preset, or the desktop's distinction is lost. + val graphite = LookPresets.byId("graphite") + val midnight = LookPresets.byId("midnight") + assertNotEquals(graphite.panelBg, midnight.panelBg) + assertEquals(graphite.accent, midnight.accent) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/LookPresetsGeneratedTest.kt b/android/app/src/test/java/com/parallelcode/phone/LookPresetsGeneratedTest.kt new file mode 100644 index 00000000..3cd91cb1 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/LookPresetsGeneratedTest.kt @@ -0,0 +1,55 @@ +package com.parallelcode.phone + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** + * Runs the look generator in `--check` mode against the checked-in + * LookPalettes.kt, so the phone's palettes cannot fall behind src/lib/look.ts or + * src/styles.css after a desktop theme is renamed, recolored or added. + * + * Skipped when the repository root is not reachable (a bare Gradle project, or a + * test run from an unpacked source jar); the Kotlin tests in LookPalettesTest + * still cover the values themselves. + */ +class LookPresetsGeneratedTest { + + @Test + fun generatedPalettesAreUpToDateWithTheDesktop() { + val repoRoot = findRepoRoot() ?: return + val generator = File(repoRoot, "scripts/generate-android-looks.mjs") + assumeTrue("generator script is missing at ${generator.path}", generator.isFile) + + val process = + ProcessBuilder("node", generator.absolutePath, "--check") + .directory(repoRoot) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().readText() + val finished = process.waitFor(2, java.util.concurrent.TimeUnit.MINUTES) + assertTrue("generator did not finish", finished) + + assertTrue( + "LookPalettes.kt is out of date with src/lib/look.ts or src/styles.css.\n" + + "Run: npm run generate:android-looks\n\n$output", + process.exitValue() == 0, + ) + } + + /** + * Walks up from the working directory looking for the generated file, so the + * test works whether Gradle runs from android/ or the repository root. + */ + private fun findRepoRoot(): File? { + // File(".") resolves against user.dir, so this avoids the nullable system property. + var dir: File? = File(".").absoluteFile + while (dir != null) { + if (File(dir, "scripts/generate-android-looks.mjs").isFile) return dir + dir = dir.parentFile + } + return null + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/MergeReadinessTest.kt b/android/app/src/test/java/com/parallelcode/phone/MergeReadinessTest.kt new file mode 100644 index 00000000..c1cd9a73 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/MergeReadinessTest.kt @@ -0,0 +1,88 @@ +package com.parallelcode.phone + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MergeReadinessTest { + @Test + fun readsVerdictChecksAndBranches() { + val readiness = + parseMergeReadiness( + JSONObject( + """ + {"readiness":{"overall":"attention","checks":[ + {"label":"Merge safety","status":"warning","detail":"main is 2 commits ahead."}, + {"label":"Verification","status":"warning","detail":"No verification was reported."}]}, + "canMerge":true,"baseBranch":"main","branchName":"task/thing"} + """.trimIndent(), + ), + ) + assertEquals("attention", readiness.overall) + assertTrue(readiness.canMerge) + assertEquals("main", readiness.baseBranch) + assertEquals("task/thing", readiness.branchName) + assertEquals(2, readiness.checks.size) + assertEquals("Merge safety", readiness.checks[0].label) + assertEquals("warning", readiness.checks[0].status) + assertEquals("main is 2 commits ahead.", readiness.checks[0].detail) + } + + /** A warning must still leave merging possible; only a blocker refuses. */ + @Test + fun warningVerdictKeepsMergeEnabled() { + val readiness = + parseMergeReadiness( + JSONObject( + """{"readiness":{"overall":"attention","checks":[ + {"label":"Verification","status":"warning","detail":"Stale."}]}, + "canMerge":true,"baseBranch":"main","branchName":"b"}""", + ), + ) + assertEquals("attention", readiness.overall) + assertTrue(readiness.canMerge) + } + + @Test + fun blockedVerdictDisablesMerge() { + val readiness = + parseMergeReadiness( + JSONObject( + """{"readiness":{"overall":"blocked","checks":[ + {"label":"Merge safety","status":"blocked","detail":"Worktree has a detached HEAD."}]}, + "canMerge":false,"baseBranch":"main","branchName":"b"}""", + ), + ) + assertEquals("blocked", readiness.overall) + assertFalse(readiness.canMerge) + assertEquals("Worktree has a detached HEAD.", readiness.checks[0].detail) + } + + /** An absent flag must fail closed, never default to allowing a merge. */ + @Test + fun missingCanMergeFailsClosed() { + val readiness = + parseMergeReadiness( + JSONObject("""{"readiness":{"overall":"ready","checks":[]},"baseBranch":"main"}"""), + ) + assertFalse(readiness.canMerge) + assertTrue(readiness.checks.isEmpty()) + } + + /** An unknown status must not crash the dialog; it renders as neutral. */ + @Test + fun unknownStatusFallsBackToNeutral() { + val readiness = + parseMergeReadiness( + JSONObject( + """{"readiness":{"overall":"ready","checks":[ + {"label":"PR checks","detail":"No PR checks available."}]}, + "canMerge":true,"baseBranch":"main","branchName":"b"}""", + ), + ) + assertEquals("neutral", readiness.checks[0].status) + assertEquals("PR checks", readiness.checks[0].label) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/PromptHistoryStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/PromptHistoryStoreTest.kt new file mode 100644 index 00000000..f81c00b6 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/PromptHistoryStoreTest.kt @@ -0,0 +1,55 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class PromptHistoryStoreTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var store: PromptHistoryStore + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + store = PromptHistoryStore(prefs) + } + + @Test + fun startsEmptyAndIgnoresBlanks() { + assertTrue(store.history("a").isEmpty()) + store.record("a", " ") + assertTrue(store.history("a").isEmpty()) + } + + @Test + fun newestFirstAndRepeatsMoveToFront() { + store.record("a", "first") + store.record("a", "second") + store.record("a", "first") + assertEquals(listOf("first", "second"), store.history("a")) + } + + @Test + fun historiesAreScopedPerAgent() { + store.record("a", "hello") + assertTrue(store.history("b").isEmpty()) + assertEquals(listOf("hello"), store.history("a")) + } + + @Test + fun capsMessagesPerAgent() { + repeat(PromptHistoryStore.MAX_PER_AGENT + 5) { store.record("a", "msg $it") } + val history = store.history("a") + assertEquals(PromptHistoryStore.MAX_PER_AGENT, history.size) + assertEquals("msg ${PromptHistoryStore.MAX_PER_AGENT + 4}", history.first()) + } + + @Test + fun survivesAReread() { + store.record("a", "hello") + val reread = PromptHistoryStore(prefs) + assertEquals(listOf("hello"), reread.history("a")) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt b/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt new file mode 100644 index 00000000..958040f3 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt @@ -0,0 +1,94 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ProtocolTest { + @Test + fun parsesTheAgentList() { + val msg = parseServerMessage( + """{"type":"agents","list":[{"agentId":"a1","taskId":"t1","taskName":"Fix login", + "status":"running","exitCode":null,"lastLine":"thinking","projectName":"web", + "attention":"needs_input"},{"agentId":"c1","taskId":"t2","taskName":"Chat", + "status":"exited","exitCode":0,"lastLine":"","attention":"idle","kind":"chat"}]}""", + ) as ServerMessage.Agents + val (terminal, chat) = msg.list + assertEquals("Fix login", terminal.taskName) + assertEquals(true, terminal.running) + assertNull(terminal.exitCode) + assertEquals("web", terminal.projectName) + assertNull(terminal.agentName) + assertEquals("needs_input", terminal.attention) + assertEquals(false, terminal.isChat) + assertEquals(false, terminal.collapsed) + assertEquals(0, chat.exitCode) + assertEquals(true, chat.isChat) + assertEquals(false, chat.collapsed) + } + + @Test + fun parsesCollapsedAgents() { + val msg = parseServerMessage( + """{"type":"agents","list":[{"agentId":"collapsed:t3","taskId":"t3","taskName":"Old Task", + "status":"exited","exitCode":null,"lastLine":"Done","attention":"idle","collapsed":true}]}""", + ) as ServerMessage.Agents + val (agent) = msg.list + assertEquals("Old Task", agent.taskName) + assertEquals(true, agent.collapsed) + assertEquals(false, agent.running) + } + + @Test + fun decodesTerminalData() { + val scrollback = parseServerMessage( + """{"type":"scrollback","agentId":"a1","data":"aGk=","cols":100}""", + ) as ServerMessage.Scrollback + assertArrayEquals("hi".toByteArray(), scrollback.data) + assertEquals(100, scrollback.cols) + assertEquals(24, scrollback.rows) + } + + @Test + fun parsesInputResults() { + assertEquals( + ServerMessage.InputResult("7", false, "busy"), + parseServerMessage("""{"type":"input-result","requestId":"7","ok":false,"error":"busy"}"""), + ) + } + + @Test + fun ignoresUnknownAndMalformedMessages() { + assertNull(parseServerMessage("""{"type":"mystery","agentId":"a"}""")) + assertNull(parseServerMessage("""{"type":"chat-state","agentId":"a"}""")) + assertNull(parseServerMessage("""{"type":"output","agentId":"a"}""")) + assertNull(parseServerMessage("nope")) + } + + @Test + fun parsesChatStateWithRequestsKeepingTheirIdType() { + val msg = parseServerMessage( + """{"type":"chat-state","agentId":"a","state":{"status":"ready","model":"opus", + "items":[{"id":"1","kind":"user","text":"hi"}, + {"id":"2","kind":"tool","text":"out","activity":{"type":"command","label":"Run","status":"failed","command":"ls"}}], + "requests":[{"id":7,"since":0,"kind":"question","text":"","questions":[ + {"id":"q","question":"Which?","isSecret":false,"multiSelect":true, + "options":[{"label":"A","description":""}]}]}]}}""", + ) as ServerMessage.Chat + assertEquals("a", msg.agentId) + assertEquals(listOf("user", "tool"), msg.state.items.map { it.kind }) + assertEquals("ls", msg.state.items[1].activity?.command) + val request = msg.state.requests.single() + assertEquals(7, request.id) + assertEquals(true, request.questions.single().multiSelect) + assertEquals("A", request.questions.single().options.single().label) + } + + @Test + fun preparesRepliesLikeThePhoneWebUi() { + assertEquals("one two", messageForTerminal(" one\r\ntwo\u0003 ", bracketedPaste = false)) + assertEquals("\u001b[200~one\ntwo\u001b[201~", messageForTerminal("one\rtwo", bracketedPaste = true)) + assertEquals("", messageForTerminal(" \u001b ", bracketedPaste = true)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt new file mode 100644 index 00000000..5b9a6445 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt @@ -0,0 +1,201 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SettingsStoreTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var store: SettingsStore + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + store = SettingsStore(prefs) + } + + @Test + fun defaultsToKeepScreenOnDisabled() { + assertFalse(store.keepScreenOn) + } + + @Test + fun enablesKeepScreenOnAndPersists() { + store.keepScreenOn = true + assertTrue(store.keepScreenOn) + assertTrue(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON, false)) + } + + @Test + fun togglesKeepScreenOnBackToDisabled() { + store.keepScreenOn = true + assertTrue(store.keepScreenOn) + store.keepScreenOn = false + assertFalse(store.keepScreenOn) + assertFalse(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON, true)) + } + + @Test + fun defaultsToKeepScreenOnOnlyWhenActiveDisabled() { + assertFalse(store.keepScreenOnOnlyWhenActive) + } + + @Test + fun enablesKeepScreenOnOnlyWhenActiveAndPersists() { + store.keepScreenOnOnlyWhenActive = true + assertTrue(store.keepScreenOnOnlyWhenActive) + assertTrue(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, false)) + } + + @Test + fun defaultsToSystemThemeMode() { + assertEquals(SettingsStore.THEME_SYSTEM, store.themeMode) + } + + @Test + fun defaultsLookPresetsToObsidianPerTone() { + assertEquals(LookPresets.PRESET_OBSIDIAN, store.darkThemePreset) + assertEquals(LookPresets.PRESET_OBSIDIAN_LIGHT, store.lightThemePreset) + } + + @Test + fun setsLookPresetsAndPersists() { + store.darkThemePreset = "catppuccin-mocha" + store.lightThemePreset = "islands-light" + assertEquals("catppuccin-mocha", store.darkThemePreset) + assertEquals("islands-light", store.lightThemePreset) + } + + @Test + fun lookPresetsSurviveSeparateToneSlots() { + store.darkThemePreset = "ember" + store.lightThemePreset = "islands-light" + assertEquals("ember", store.darkThemePreset) + assertEquals("islands-light", store.lightThemePreset) + } + + @Test + fun unknownLookPresetFallsBackToToneDefault() { + prefs.edit { putString(SettingsStore.KEY_DARK_THEME_PRESET, "solarized-ultra") } + prefs.edit { putString(SettingsStore.KEY_LIGHT_THEME_PRESET, "solarized-ultra") } + assertEquals(LookPresets.PRESET_OBSIDIAN, store.darkThemePreset) + assertEquals(LookPresets.PRESET_OBSIDIAN_LIGHT, store.lightThemePreset) + } + + /** A light look saved into the dark slot must not be drawn in dark mode. */ + @Test + fun lightPresetInDarkSlotFallsBackToDarkDefault() { + prefs.edit { putString(SettingsStore.KEY_DARK_THEME_PRESET, LookPresets.PRESET_OBSIDIAN_LIGHT) } + assertEquals(LookPresets.PRESET_OBSIDIAN, store.darkThemePreset) + } + + @Test + fun darkPresetInLightSlotFallsBackToLightDefault() { + prefs.edit { putString(SettingsStore.KEY_LIGHT_THEME_PRESET, LookPresets.PRESET_OBSIDIAN) } + assertEquals(LookPresets.PRESET_OBSIDIAN_LIGHT, store.lightThemePreset) + } + + /** Assigning the wrong tone normalizes on write instead of storing a mismatch. */ + @Test + fun writingWrongTonePresetNormalizes() { + store.lightThemePreset = LookPresets.PRESET_OBSIDIAN + assertEquals(LookPresets.PRESET_OBSIDIAN_LIGHT, store.lightThemePreset) + assertEquals(LookPresets.PRESET_OBSIDIAN_LIGHT, prefs.getString(SettingsStore.KEY_LIGHT_THEME_PRESET, null)) + } + + @Test + fun setsThemeModeAndPersists() { + store.themeMode = SettingsStore.THEME_DARK + assertEquals(SettingsStore.THEME_DARK, store.themeMode) + assertEquals(SettingsStore.THEME_DARK, prefs.getString(SettingsStore.KEY_THEME_MODE, null)) + + store.themeMode = SettingsStore.THEME_LIGHT + assertEquals(SettingsStore.THEME_LIGHT, store.themeMode) + assertEquals(SettingsStore.THEME_LIGHT, prefs.getString(SettingsStore.KEY_THEME_MODE, null)) + } + + @Test + fun defaultsToShowMinimizedTasksDisabled() { + assertFalse(store.showMinimizedTasks) + } + + @Test + fun enablesShowMinimizedTasksAndPersists() { + store.showMinimizedTasks = true + assertTrue(store.showMinimizedTasks) + assertTrue(prefs.getBoolean(SettingsStore.KEY_SHOW_MINIMIZED_TASKS, false)) + + store.showMinimizedTasks = false + assertFalse(store.showMinimizedTasks) + assertFalse(prefs.getBoolean(SettingsStore.KEY_SHOW_MINIMIZED_TASKS, true)) + } + + @Test + fun quickRepliesDefaultAndDropBlankLines() { + assertEquals(SettingsStore.DEFAULT_QUICK_REPLIES, store.quickReplies) + store.quickReplies = listOf(" continue ", "", "ship it") + assertEquals(listOf("continue", "ship it"), store.quickReplies) + } + + @Test + fun appendsToADraftLikeTyping() { + assertEquals("yes", appendToDraft(" ", "yes")) + assertEquals("ok yes", appendToDraft("ok", "yes")) + assertEquals("ok yes", appendToDraft("ok ", "yes")) + } + + @Test + fun sendQuickRepliesDefaultsOffAndPersists() { + assertFalse(store.sendQuickReplies) + store.sendQuickReplies = true + assertTrue(store.sendQuickReplies) + assertTrue(prefs.getBoolean(SettingsStore.KEY_SEND_QUICK_REPLIES, false)) + } + + @Test + fun alwaysFollowOutputDefaultsOffAndPersists() { + assertFalse(store.alwaysFollowOutput) + store.alwaysFollowOutput = true + assertTrue(store.alwaysFollowOutput) + assertTrue(prefs.getBoolean(SettingsStore.KEY_ALWAYS_FOLLOW_OUTPUT, false)) + } + + @Test + fun widgetTransparencyDefaultsToOpaqueAndPersists() { + assertEquals(100, store.widgetTransparency) + store.widgetTransparency = 50 + assertEquals(50, store.widgetTransparency) + assertEquals(50, prefs.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 100)) + } + + @Test + fun widgetTransparencySnapsToAStopOnTheWayInAndOut() { + store.widgetTransparency = 90 + assertEquals(100, store.widgetTransparency) + assertEquals(100, prefs.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 0)) + + // A value written by an older build still reads back as a real stop. + prefs.edit().putInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 42).apply() + assertEquals(50, store.widgetTransparency) + } + + @Test + fun widgetPaletteDefaultsToObsidianAndPersists() { + assertEquals("obsidian", store.widgetPalette) + store.widgetPalette = "light" + assertEquals("light", store.widgetPalette) + assertEquals("light", prefs.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + } + + @Test + fun unknownWidgetPaletteFallsBackToObsidian() { + store.widgetPalette = "chartreuse" + assertEquals("obsidian", store.widgetPalette) + assertEquals("obsidian", prefs.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt b/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt new file mode 100644 index 00000000..5c96b93b --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt @@ -0,0 +1,67 @@ +package com.parallelcode.phone + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TaskDiffTest { + @Test + fun splitsFilesAndCountsLines() { + val files = parseUnifiedDiff( + """ + diff --git a/src/a.kt b/src/a.kt + index 1..2 100644 + --- a/src/a.kt + +++ b/src/a.kt + @@ -1,2 +1,2 @@ + keep + -old + +new + +more + diff --git a/gone.txt b/gone.txt + deleted file mode 100644 + --- a/gone.txt + +++ /dev/null + @@ -1 +0,0 @@ + -bye + diff --git a/logo.png b/logo.png + Binary files a/logo.png and b/logo.png differ + """.trimIndent(), + ) + assertEquals(listOf("src/a.kt", "gone.txt", "logo.png"), files.map { it.path }) + assertEquals(2, files[0].added) + assertEquals(1, files[0].removed) + assertEquals(listOf("@@ -1,2 +1,2 @@", " keep", "-old", "+new", "+more"), files[0].lines) + assertEquals(1, files[1].removed) + assertTrue(files[2].binary) + } + + @Test + fun emptyDiffHasNoFiles() { + assertEquals(emptyList(), parseUnifiedDiff("")) + } + + @Test + fun readsTheDiffFlagsFromTheDesktop() { + val plain = TaskDiff.from(JSONObject("""{"diff":"x","truncated":false}""")) + assertEquals("x", plain.diff) + assertEquals(false, plain.truncated) + assertEquals(false, plain.unsupported) + + // A task with no branch of its own: an empty diff plus the flag, so the + // phone explains itself rather than reporting "no changes". + val none = TaskDiff.from(JSONObject("""{"diff":"","truncated":false,"unsupported":true}""")) + assertEquals("", none.diff) + assertEquals(true, none.unsupported) + } + + @Test + fun aDiffWithoutFlagsDefaultsToVisibleAndSupported() { + // An older desktop omits both fields; the phone must still show the diff. + val legacy = TaskDiff.from(JSONObject("{}")) + assertEquals("", legacy.diff) + assertEquals(false, legacy.truncated) + assertEquals(false, legacy.unsupported) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt new file mode 100644 index 00000000..95ed77af --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt @@ -0,0 +1,44 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalBufferTest { + @Test + fun updatesVersionAndResetsScreenOnScrollback() { + val buffer = TerminalBuffer("agent-1") + assertEquals(0, buffer.version.value) + + buffer.onScrollback("initial output\r\n".toByteArray(), cols = 40, rows = 10) + assertEquals(1, buffer.version.value) + assertTrue(buffer.screen.text().contains("initial output")) + } + + @Test + fun updatesVersionAndAppendsOutput() { + val buffer = TerminalBuffer("agent-1") + buffer.onScrollback("step 1\r\n".toByteArray(), cols = 40, rows = 10) + assertEquals(1, buffer.version.value) + + buffer.onOutput("step 2\r\n".toByteArray()) + assertEquals(2, buffer.version.value) + val text = buffer.screen.text() + assertTrue(text.contains("step 1")) + assertTrue(text.contains("step 2")) + } + + @Test + fun retainsDeepScrollbackHistory() { + val buffer = TerminalBuffer("agent-1") + // Feed 100 lines through a 5-row screen + buffer.onScrollback("".toByteArray(), cols = 40, rows = 5) + for (i in 1..100) { + buffer.onOutput("log line $i\r\n".toByteArray()) + } + val text = buffer.screen.text() + assertTrue(text.contains("log line 1")) + assertTrue(text.contains("log line 100")) + assertTrue(buffer.screen.styledLines().size >= 100) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt new file mode 100644 index 00000000..c76fe723 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt @@ -0,0 +1,144 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalScreenTest { + private fun screen(cols: Int = 10, rows: Int = 3, data: String = "") = + TerminalScreen().apply { reset(cols, rows, data.toByteArray()) } + + @Test + fun appliesCursorMovementAndErase() { + val s = screen(data = "hello\r\nworld\u001b[1;1Hj\u001b[2;3H\u001b[K") + assertEquals("jello\nwo", s.text()) + } + + @Test + fun dropsColorsAndTitles() { + val s = screen(data = "\u001b]0;title\u0007\u001b[1;31mred\u001b[0m ok") + assertEquals("red ok", s.text()) + } + + @Test + fun scrollsFullLinesIntoHistory() { + val s = screen(rows = 2, data = "1\r\n2\r\n3\r\n4") + assertEquals("1\n2\n3\n4", s.text()) + } + + @Test + fun wrapsOnlyWhenTheNextCharacterArrives() { + val s = screen(cols = 3, data = "abc\r\nd") + assertEquals("abc\nd", s.text()) + assertEquals("abc\nd", screen(cols = 3, data = "abcd").text()) + } + + @Test + fun preservesHistoryWhenScrollRegionStartsAtTop() { + // Status line pinned at the bottom; lines scrolling off the top go to history. + val s = screen(rows = 3, data = "\u001b[3;1Hstatus\u001b[1;2r\u001b[1;1Ha\r\nb\r\nc") + assertEquals("a\nb\nc\nstatus", s.text()) + } + + @Test + fun keepsSubRegionRedrawsOutOfHistoryWhenHeaderPinned() { + // Header pinned at row 1; only rows 2-3 scroll. + val s = screen(rows = 3, data = "\u001b[1;1Hheader\u001b[2;3r\u001b[2;1Ha\r\nb\r\nc") + assertEquals("header\nb\nc", s.text()) + } + + @Test + fun preservesHistoryAcrossMultipleScrollsWithPinnedFooter() { + // TUI with 4 rows: row 4 is a pinned status footer, rows 1-3 scroll. + // Print 10 lines through the scrolling region. + val input = StringBuilder("\u001b[4;1Hfooter\u001b[1;3r\u001b[1;1H") + for (i in 1..10) { + input.append("line $i\r\n") + } + val s = screen(rows = 4, data = input.toString()) + val text = s.text() + assertTrue(text.contains("line 1")) + assertTrue(text.contains("line 10")) + assertTrue(text.contains("footer")) + // 7 lines scrolled into history + 4 lines on grid (including trailing lines) + val lines = s.styledLines() + assertTrue(lines.size >= 10) + } + + @Test + fun restoresTheMainScreenAfterAFullScreenProgram() { + val s = screen(data = "shell\u001b[?1049hvim stuff\u001b[?1049l") + assertEquals("shell", s.text()) + } + + @Test + fun decodesUtf8SplitAcrossChunks() { + val s = screen() + val bytes = "é✓".toByteArray() + s.feed(bytes.copyOfRange(0, 1)) + s.feed(bytes.copyOfRange(1, bytes.size)) + assertEquals("é✓", s.text()) + } + + @Test + fun tracksBracketedPasteMode() { + val s = screen(data = "\u001b[?2004h") + assertTrue(s.bracketedPaste) + s.feed("\u001b[?2004l".toByteArray()) + assertEquals(false, s.bracketedPaste) + } + + @Test + fun keepsColorsPerCell() { + val s = screen(cols = 20, data = "\u001b[31mred\u001b[0m \u001b[1;38;2;1;2;3mrgb\u001b[m") + val (red, plain, rgb) = s.styledLines().single() + assertEquals("red", red.text) + assertEquals(1, CellStyle.fg(red.style)) + assertEquals(CellStyle.DEFAULT, plain.style) + assertEquals(CellStyle.RGB_FLAG or 0x010203, CellStyle.fg(rgb.style)) + assertTrue(CellStyle.flags(rgb.style) and CellStyle.BOLD != 0) + } + + @Test + fun erasePaintsTheCurrentBackground() { + val s = screen(cols = 4, rows = 1, data = "\u001b[44m\u001b[2K\u001b[0mx") + val spans = s.styledLines().single() + assertEquals(listOf("x", " "), spans.map { it.text }) + assertEquals(4, CellStyle.bg(spans[1].style)) + } + + @Test + fun retainsUpTo5000LinesOfHistory() { + val s = screen(rows = 2) + // Feed 5,050 lines + for (i in 1..5050) { + s.feed("L$i\r\n".toByteArray()) + } + val lines = s.styledLines() + // Should keep 5000 history lines + 1 non-empty grid row (trailing blank row is trimmed) + assertEquals(5001, lines.size) + // Earliest line should be L50, since 1-49 were pruned past 5000 + val text = s.text() + assertTrue(text.contains("L5050")) + assertTrue(text.contains("L52")) + assertEquals(false, text.contains("L40\n")) + } + + @Test + fun resizeKeepsTheCursorLineAndScrollsTheTopIntoHistory() { + val s = screen(cols = 5, rows = 3, data = "a\r\nb\r\nc") + s.resize(4, 2) + assertEquals("a\nb\nc", s.text()) + s.feed("\u001b[1;1Hx".toByteArray()) + assertEquals("a\nx\nc", s.text()) + } + + @Test + fun resizeGrowsAndTruncatesColumns() { + val s = screen(cols = 5, rows = 2, data = "abcde") + s.resize(3, 4) + assertEquals("abc", s.text()) + s.feed("\u001b[4;1Hz".toByteArray()) + assertEquals("abc\n\n\nz", s.text()) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt new file mode 100644 index 00000000..3027fcbb --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt @@ -0,0 +1,79 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalStyleTest { + private fun sgr(body: String, from: Long = CellStyle.DEFAULT) = CellStyle.applySgr(from, body) + + @Test + fun parsesBasicBrightAnd256Colors() { + assertEquals(2, CellStyle.fg(sgr("32"))) + assertEquals(12, CellStyle.bg(sgr("104"))) + assertEquals(208, CellStyle.fg(sgr("38;5;208"))) + assertEquals(17, CellStyle.bg(sgr("48:5:17"))) + } + + @Test + fun parsesTrueColorInBothForms() { + val rgb = CellStyle.RGB_FLAG or 0x0a141e + assertEquals(rgb, CellStyle.fg(sgr("38;2;10;20;30"))) + assertEquals(rgb, CellStyle.fg(sgr("38:2::10:20:30"))) + assertEquals(rgb, CellStyle.bg(sgr("48:2:10:20:30"))) + // Parameters after an extended color still apply. + assertTrue(CellStyle.flags(sgr("38;2;10;20;30;1")) and CellStyle.BOLD != 0) + } + + @Test + fun resetsAndClearsAttributes() { + val styled = sgr("1;3;4;7;31;42") + assertEquals(CellStyle.DEFAULT, sgr("0", styled)) + assertEquals(CellStyle.DEFAULT, sgr("", styled)) + val cleared = sgr("22;23;24;27;39;49", styled) + assertEquals(CellStyle.DEFAULT, cleared) + } + + /** Obsidian's palette, which the desktop pairs with the Obsidian look. */ + private val obsidian = TerminalPalette.forLook(LookPresets.byId(LookPresets.PRESET_OBSIDIAN)) + + @Test + fun resolvesThroughTheDesktopPalette() { + val p = obsidian + assertEquals(0xFFe08c96.toInt(), p.resolve(sgr("31")).foreground) + // Bold basic colors brighten, as in xterm.js. + assertEquals(0xFFeaa0aa.toInt(), p.resolve(sgr("1;31")).foreground) + assertEquals(0xFFff8700.toInt(), p.resolve(sgr("38;5;208")).foreground) + assertEquals(0xFF808080.toInt(), p.resolve(sgr("38;5;244")).foreground) + assertNull(p.resolve(CellStyle.DEFAULT).background) + } + + @Test + fun inverseSwapsWithTheThemeDefaults() { + val p = obsidian + val s = p.resolve(sgr("7")) + assertEquals(p.background, s.foreground) + assertEquals(p.foreground, s.background) + } + + /** + * Each look draws its terminal over its own panel, not a shared background. + * Midnight is the case that matters: it is Graphite with a pure-black panel. + */ + @Test + fun eachLookUsesItsOwnTerminalBackground() { + val midnight = TerminalPalette.forLook(LookPresets.byId("midnight")) + val graphite = TerminalPalette.forLook(LookPresets.byId("graphite")) + assertNotEquals(graphite.background, midnight.background) + assertEquals(0xFF000000.toInt(), midnight.background) + } + + /** A look's terminal colors change with it, rather than staying on Obsidian. */ + @Test + fun looksWithoutTheirOwnAnsiStillDifferInColor() { + val noir = TerminalPalette.forLook(LookPresets.byId("noir")) + assertNotEquals(obsidian.resolve(sgr("31")).foreground, noir.resolve(sgr("31")).foreground) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt b/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt new file mode 100644 index 00000000..d1a42eaa --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt @@ -0,0 +1,72 @@ +package com.parallelcode.phone + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.Locale + +class UsageTest { + @Test + fun showsProvidersWithASnapshotOrARefreshError() { + val usage = parseUsage( + JSONObject( + """{ + "claude": {"fiveHour": {"usedPercent": 82.4, "resetsAt": 1700000000000}, + "sevenDay": {"usedPercent": 10, "resetsAt": null}, + "fetchedAt": 1, "status": "ok", "error": null}, + "codex": {"fiveHour": null, "sevenDay": null, "fetchedAt": null, + "status": "error", "error": "rate limited"} + }""", + ), + ) + val (claude, codex) = usage + assertEquals("Claude", claude.label) + assertEquals(18, claude.fiveHour?.remainingPercent) + assertTrue(claude.fiveHour?.warn == true) + assertEquals(null, claude.sevenDay?.resetsAt) + assertEquals("rate limited", codex.error) + } + + @Test + fun showsAntigravityUsageWhenAvailable() { + val usage = parseUsage( + JSONObject( + """{ + "antigravity": { + "fiveHour": {"usedPercent": 40.0, "resetsAt": 1700000000000}, + "sevenDay": {"usedPercent": 15.0, "resetsAt": null}, + "fetchedAt": 1, + "status": "ok", + "error": null + } + }""", + ), + ) + assertEquals(1, usage.size) + val agy = usage.first() + assertEquals("Antigravity", agy.label) + assertEquals(60, agy.fiveHour?.remainingPercent) + assertEquals(85, agy.sevenDay?.remainingPercent) + } + + @Test + fun hidesProvidersWithoutASubscription() { + val none = """{"fiveHour": null, "sevenDay": null, "fetchedAt": null, "status": "unavailable", "error": null}""" + assertEquals(emptyList(), parseUsage(JSONObject("""{"claude": $none, "codex": $none, "antigravity": $none}"""))) + } + + @Test + fun formatsResetTimes() { + val zone = ZoneOffset.UTC + val now = ZonedDateTime.of(2026, 9, 28, 10, 0, 0, 0, zone).toInstant().toEpochMilli() + fun at(day: Int, hour: Int) = ZonedDateTime.of(2026, 9, day, hour, 30, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("resets 14:30", formatReset(at(28, 14), now, zone, Locale.US)) + val thursday = ZonedDateTime.of(2026, 10, 1, 9, 30, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("resets Thu 09:30", formatReset(thursday, now, zone, Locale.US)) + assertEquals("reset due", formatReset(at(28, 9), now, zone, Locale.US)) + assertEquals("", formatReset(null, now, zone, Locale.US)) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..12a7ab91 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.4.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..459e0db2 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..b1b8ef56 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a9db1155 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..249efbb0 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..8508ef68 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..9db5f17b --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ParallelCodePhone" +include(":app") diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 2ebfca44..6528e4fd 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -96,6 +96,10 @@ "MCP_PublishTourRequest": "mcp_publish_tour_request", "Remote_GetNotesRequest": "remote_get_notes_request", "Remote_SetNotesRequest": "remote_set_notes_request", + "Remote_CloseTaskRequest": "remote_close_task_request", + "Remote_GetDiffRequest": "remote_get_diff_request", + "Remote_GetMergeReadinessRequest": "remote_get_merge_readiness_request", + "Remote_MergeTaskRequest": "remote_merge_task_request", "Remote_UpdateTaskStatus": "remote_update_task_status", "Remote_RendererReply": "remote_renderer_reply", "PlanContent": "plan_content", diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 23cedd10..29b558fc 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -108,10 +108,12 @@ import { onPtyEvent, projectImageTag, resizeAgent, + setAgentRemoteSize, resolveProjectDockerfile, spawnAgent, setAgentHookRuntime, subscribeToAgent, + subscribeToAgentRendered, validateCommand, writeToAgent, writeAgentPrompt, @@ -1105,6 +1107,18 @@ describe('spawnAgent terminal queries', () => { await vi.waitFor(() => expect(proc.write).toHaveBeenCalledWith('\x1b[1;3R')); }); + it('takes a phone size and restores the desktop size, remembering desktop resizes', async () => { + const proc = await launch('agent-remote-size'); + resizeAgent('agent-remote-size', 100, 30); + setAgentRemoteSize('agent-remote-size', { cols: 60, rows: 50 }); + expect(proc.resize).toHaveBeenLastCalledWith(60, 50); + // The desktop pane refits while the phone views: remembered, not applied. + resizeAgent('agent-remote-size', 110, 32); + expect(proc.resize).toHaveBeenLastCalledWith(60, 50); + setAgentRemoteSize('agent-remote-size', null); + expect(proc.resize).toHaveBeenLastCalledWith(110, 32); + }); + it('answers at the size the PTY was resized to', async () => { const proc = await launch('agent-query-resize'); proc.emitData('\r\n'.repeat(30)); @@ -2173,3 +2187,42 @@ describe('Claude terminal handoff', () => { } }); }); + +describe('subscribeToAgentRendered', () => { + async function launch(agentId: string) { + await spawnAgent( + createMockNotify(), + buildSpawnArgs({ agentId, command: 'claude', args: [], dockerMode: false }), + ); + return mockPtySpawn.mock.results[mockPtySpawn.mock.results.length - 1].value as ReturnType< + typeof mockPtySpawn + >; + } + + const decode = (encoded: string) => Buffer.from(encoded, 'base64').toString(); + + it('starts from the rendered screen and sends later output once, after it', async () => { + const proc = await launch('agent-rendered'); + // Repaints of one line: the raw replay keeps every frame, the render only the last. + proc.emitData('first\r\nframe 1\rframe 2\rframe 3'); + const events: string[] = []; + const subscriber = subscribeToAgentRendered( + 'agent-rendered', + (snapshot) => events.push(`snapshot:${decode(snapshot?.data ?? '')}`), + (encoded) => events.push(`output:${decode(encoded)}`), + ); + expect(subscriber).not.toBeNull(); + proc.emitData('\r\nlater'); + + await vi.waitFor(() => expect(events.some((e) => e === 'output:\r\nlater')).toBe(true)); + expect(events).toHaveLength(2); + expect(events[0]).toContain('first'); + expect(events[0]).toContain('frame 3'); + expect(events[0]).not.toContain('frame 1'); + expect(events[1]).toBe('output:\r\nlater'); + }); + + it('returns null for an unknown agent', () => { + expect(subscribeToAgentRendered('missing', vi.fn(), vi.fn())).toBeNull(); + }); +}); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 99060168..c46f4a1f 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -53,6 +53,12 @@ interface PtySession { peerInputQueue?: { data: string; at: number }[]; /** Assigned container name when running in Docker mode, null otherwise. */ containerName: string | null; + /** The size the desktop pane asked for; restored when a phone stops overriding it. */ + desktopSize?: { cols: number; rows: number }; + /** A viewing phone's size, which the PTY takes while it is set. */ + remoteSize?: { cols: number; rows: number }; + /** Send batched output now, so subscribers have everything the mirror has parsed. */ + flushOutput?: () => void; } const sessions = new Map(); @@ -631,6 +637,8 @@ function attachPtyOutputHandlers( } }; + session.flushOutput = flush; + session.proc.onData((data: string) => { session.queries.feed(data); const chunk = Buffer.from(data, 'utf8'); @@ -754,10 +762,7 @@ export async function spawnAgent( existing.taskId = args.taskId; existing.isShell = args.isShell ?? existing.isShell; existing.proc.resume(); - if (args.cols > 0 && args.rows > 0) { - existing.proc.resize(args.cols, args.rows); - existing.queries.resize(args.cols, args.rows); - } + if (args.cols > 0 && args.rows > 0) resizeDesktopView(existing, args.cols, args.rows); if (existing.scrollback.length > 0) { sendToChannel(notify, channelId, { type: 'Data', @@ -1012,11 +1017,44 @@ export async function writeAgentPrompt( } } +function applySize(session: PtySession, cols: number, rows: number): void { + if (session.proc.cols === cols && session.proc.rows === rows) return; + session.proc.resize(cols, rows); + session.queries.resize(cols, rows); +} + +function resizeDesktopView(session: PtySession, cols: number, rows: number): void { + session.desktopSize = { cols, rows }; + if (!session.remoteSize) applySize(session, cols, rows); +} + +/** Resize for the desktop pane. While a phone overrides the size, it is remembered instead. */ export function resizeAgent(agentId: string, cols: number, rows: number): void { const session = sessions.get(agentId); if (!session) throw new Error(`Agent not found: ${agentId}`); - session.proc.resize(cols, rows); - session.queries.resize(cols, rows); + resizeDesktopView(session, cols, rows); +} + +/** + * Give the PTY a viewing phone's size, so a full-screen TUI fills the phone, or + * pass null to hand it back to the desktop pane's last size. + */ +export function setAgentRemoteSize( + agentId: string, + size: { cols: number; rows: number } | null, +): void { + const session = sessions.get(agentId); + if (!session) throw new Error(`Agent not found: ${agentId}`); + if (size) { + session.desktopSize ??= { cols: session.proc.cols, rows: session.proc.rows }; + session.remoteSize = size; + applySize(session, size.cols, size.rows); + return; + } + if (!session.remoteSize) return; + session.remoteSize = undefined; + const desktop = session.desktopSize; + if (desktop) applySize(session, desktop.cols, desktop.rows); } export function pauseAgent(agentId: string): void { @@ -1094,6 +1132,41 @@ export function subscribeToAgent(agentId: string, cb: (encoded: string) => void) return true; } +/** + * Subscribe starting from a rendered snapshot: the screen and up to 1000 lines + * of history from the main-process mirror, as ANSI text, instead of the raw + * byte replay, which redraw-heavy TUIs fill with repaints of one screen. + * Output already in the snapshot is not sent again; later output reaches `cb` + * after `onSnapshot`. The snapshot is null when the mirror is gone (the process + * exited), so the caller can fall back to the raw replay. Returns the + * subscriber to pass to unsubscribeFromAgent, or null for an unknown agent. + */ +export function subscribeToAgentRendered( + agentId: string, + onSnapshot: (snapshot: { data: string; cols: number; rows: number } | null) => void, + cb: (encoded: string) => void, +): ((encoded: string) => void) | null { + const session = sessions.get(agentId); + if (!session) return null; + // The mirror parses output as it arrives but subscribers get it in batches: + // flush, so what the snapshot will contain has all been sent already. + session.flushOutput?.(); + const { cols, rows } = session.proc; + let queued: string[] | null = []; + const subscriber = (encoded: string) => { + if (queued) queued.push(encoded); + else cb(encoded); + }; + session.subscribers.add(subscriber); + void session.queries.serialize().then((text) => { + const later = queued ?? []; + queued = null; + onSnapshot(text === null ? null : { data: Buffer.from(text).toString('base64'), cols, rows }); + for (const encoded of later) cb(encoded); + }); + return subscriber; +} + /** Remove a previously registered output subscriber. */ export function unsubscribeFromAgent(agentId: string, cb: (encoded: string) => void): void { sessions.get(agentId)?.subscribers.delete(cb); diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 533e49f3..4ba0bcf1 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -81,9 +81,13 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.NotificationFailed, IPC.PlanContent, IPC.PrChecksUpdate, + IPC.Remote_CloseTaskRequest, IPC.Remote_CreateTaskRequest, + IPC.Remote_GetDiffRequest, IPC.Remote_GetNotesRequest, IPC.Remote_GetProjectsRequest, + IPC.Remote_GetMergeReadinessRequest, + IPC.Remote_MergeTaskRequest, IPC.Remote_SetNotesRequest, IPC.StepsContent, IPC.SuperProductivityOpenTaskRequested, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 374da300..1133b25c 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -70,7 +70,13 @@ import { readCoverageSummary } from './coverage.js'; import { loadEslintQualityFindings } from './eslint-quality-findings.js'; import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js'; import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; -import type { RemoteAttentionState, RemoteAgent } from '../remote/protocol.js'; +import type { + RemoteAttentionState, + RemoteCloseResult, + RemoteMergeReadiness, + RemoteTaskContext, + RemoteTaskDiff, +} from '../remote/protocol.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; import { getUserDataDir } from '../user-data-dir.js'; import { @@ -578,10 +584,7 @@ export function registerAllHandlers(win: BrowserWindow): void { // the same richer status as the desktop. The renderer owns this computation // (it depends on reactive terminal/git/steps state), so main just caches it. const taskAttention = new Map(); - const taskContext = new Map< - string, - Pick - >(); + const taskContext = new Map(); // --- MCP coordinator (lazy — only loaded when coordinator mode is enabled) --- let coordinatorHandlersRegistered = false; @@ -1666,8 +1669,23 @@ export function registerAllHandlers(win: BrowserWindow): void { callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes), setTaskNotes: (taskId: string, notes: string) => callRenderer<{ ok: boolean }>(IPC.Remote_SetNotesRequest, { taskId, notes }).then(() => {}), + closeTaskFromMobile: (taskId: string, force: boolean) => + callRenderer(IPC.Remote_CloseTaskRequest, { taskId, force }), + getTaskDiff: (taskId: string) => + callRenderer(IPC.Remote_GetDiffRequest, { taskId }), + getMergeReadiness: (taskId: string) => + callRenderer(IPC.Remote_GetMergeReadinessRequest, { taskId }), + mergeTaskFromMobile: (req: { taskId: string; squash: boolean; cleanup: boolean }) => + callRenderer<{ ok: boolean }>(IPC.Remote_MergeTaskRequest, req).then(() => {}), getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle', getTaskContext: (taskId: string) => taskContext.get(taskId), + getCollapsedTaskIds: (): string[] => { + const result: string[] = []; + for (const [taskId, ctx] of taskContext.entries()) { + if (ctx.collapsed) result.push(taskId); + } + return result; + }, }; const remoteServerOptions = (): Omit< @@ -1711,7 +1729,14 @@ export function registerAllHandlers(win: BrowserWindow): void { statuses?: Record; contexts?: Record< string, - { projectName?: unknown; projectColor?: unknown; agentName?: unknown; lastLine?: unknown } + { + projectName?: unknown; + projectColor?: unknown; + agentName?: unknown; + lastLine?: unknown; + taskName?: unknown; + collapsed?: unknown; + } >; }, ) => { @@ -1722,7 +1747,12 @@ export function registerAllHandlers(win: BrowserWindow): void { if (args.contexts && typeof args.contexts === 'object') { for (const [taskId, context] of Object.entries(args.contexts)) { if (!context || typeof context !== 'object') continue; + const taskName = + typeof context.taskName === 'string' ? context.taskName.slice(0, 200) : undefined; + if (taskName) taskNames.set(taskId, taskName); taskContext.set(taskId, { + taskName, + collapsed: Boolean(context.collapsed), projectName: typeof context.projectName === 'string' ? context.projectName.slice(0, 200) : '', projectColor: diff --git a/electron/ipc/terminal-query-responder.ts b/electron/ipc/terminal-query-responder.ts index a3cce0ed..55b468d0 100644 --- a/electron/ipc/terminal-query-responder.ts +++ b/electron/ipc/terminal-query-responder.ts @@ -1,14 +1,17 @@ import headless from '@xterm/headless'; +import serializeAddon from '@xterm/addon-serialize'; const { Terminal } = headless; +const { SerializeAddon } = serializeAddon; /** * Lines kept above the viewport. Widening a terminal unwraps lines and pulls * scrollback back into view, which moves the cursor row, so the mirror needs - * about a screen's worth to agree with the renderer's xterm. Far below the - * renderer's 10k because one mirror runs per PTY in the main process. + * about a screen's worth to agree with the renderer's xterm. Phones are sent + * this history when they open a terminal. Far below the renderer's 10k because + * one mirror runs per PTY in the main process. */ -const SCROLLBACK_LINES = 200; +const SCROLLBACK_LINES = 1000; // A cursor position report: CSI row;col R, or the DEC form CSI ? row;col R. // eslint-disable-next-line no-control-regex @@ -26,6 +29,11 @@ export interface TerminalQueryResponder { feedDisplayOnly(data: string): void; /** Current visible screen and input mode; null until queued output has been parsed. */ snapshot(): { text: string; bracketedPaste: boolean } | null; + /** + * The screen and history as ANSI text that redraws them, once everything fed + * so far is parsed. Null once disposed. + */ + serialize(): Promise; resize(cols: number, rows: number): void; dispose(): void; } @@ -48,6 +56,8 @@ export function createTerminalQueryResponder(opts: { rows: Math.max(1, opts.rows), scrollback: SCROLLBACK_LINES, }); + const serializer = new SerializeAddon(); + term.loadAddon(serializer); let muted = 0; let disposed = false; let pendingWrites = 0; @@ -84,6 +94,13 @@ export function createTerminalQueryResponder(opts: { } return { text: lines.join('\n'), bracketedPaste: term.modes.bracketedPasteMode }; }, + serialize() { + if (disposed) return Promise.resolve(null); + // Writes are parsed in order, so this callback runs after all earlier feeds. + return new Promise((resolve) => { + term.write('', () => resolve(disposed ? null : serializer.serialize())); + }); + }, resize(cols, rows) { if (!disposed && cols > 0 && rows > 0) term.resize(cols, rows); }, diff --git a/electron/preload.cjs b/electron/preload.cjs index 1278a0f7..8e04b11d 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -103,6 +103,10 @@ const ALLOWED_CHANNELS = new Set([ 'mcp_publish_tour_request', 'remote_get_notes_request', 'remote_set_notes_request', + 'remote_close_task_request', + 'remote_get_diff_request', + 'remote_get_merge_readiness_request', + 'remote_merge_task_request', 'remote_update_task_status', 'remote_renderer_reply', 'plan_content', diff --git a/electron/remote/close-route.test.ts b/electron/remote/close-route.test.ts new file mode 100644 index 00000000..8bed0046 --- /dev/null +++ b/electron/remote/close-route.test.ts @@ -0,0 +1,186 @@ +// HTTP access control + validation for the mobile task-close route +// (POST /api/mobile/tasks/:taskId/close). Closing removes a worktree, so only +// the paired token may call it, and without force the desktop's warnings +// come back as a 409 instead of a close. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteCloseResult } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let closeTaskFromMobile: Mock<(taskId: string, force: boolean) => Promise>; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + closeTaskFromMobile, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + closeTaskFromMobile = vi.fn( + async (_taskId: string, force: boolean): Promise => + force ? { closed: true } : { closed: false, warnings: ['uncommitted changes'] }, + ); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const PATH = '/api/mobile/tasks/task-1/close'; + +describe('POST /api/mobile/tasks/:taskId/close', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + const res = await request('POST', PATH, { body: {} }); + expect(res.status).toBe(401); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for the view-only mobile token', async () => { + const res = await request('POST', PATH, { token: mobileToken, body: { force: true } }); + expect(res.status).toBe(403); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(403); + } + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 409 with the warnings when closing would lose work', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: {} }); + expect(res.status).toBe(409); + expect(res.json).toEqual({ + error: 'closing would lose work', + warnings: ['uncommitted changes'], + }); + expect(closeTaskFromMobile).toHaveBeenCalledWith('task-1', false); + }); + + it('closes with force for a paired token', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ ok: true }); + expect(closeTaskFromMobile).toHaveBeenCalledWith('task-1', true); + }); + + it('rejects a non-boolean force', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: 'yes' } }); + expect(res.status).toBe(400); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('rejects a malformed escape and prototype keys', async () => { + const token = await pair(); + for (const id of ['%', '__proto__', 'constructor']) { + const res = await request('POST', `/api/mobile/tasks/${id}/close`, { token, body: {} }); + expect(res.status).toBe(400); + } + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 405 for other methods', async () => { + const token = await pair(); + const res = await request('GET', PATH, { token }); + expect(res.status).toBe(405); + }); + + it('returns 503 when the desktop cannot close tasks', async () => { + await stop(); + await start({ closeTaskFromMobile: undefined }); + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(503); + }); +}); diff --git a/electron/remote/coordinator-scoping.test.ts b/electron/remote/coordinator-scoping.test.ts index 1add1904..85227c6a 100644 --- a/electron/remote/coordinator-scoping.test.ts +++ b/electron/remote/coordinator-scoping.test.ts @@ -12,6 +12,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/diff-route.test.ts b/electron/remote/diff-route.test.ts new file mode 100644 index 00000000..02775710 --- /dev/null +++ b/electron/remote/diff-route.test.ts @@ -0,0 +1,158 @@ +// HTTP access control + validation for the mobile task-diff route +// (GET /api/mobile/tasks/:taskId/diff): readable by the view-only and paired +// phone tokens, like notes, and by nothing else. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteTaskDiff } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let getTaskDiff: Mock<(taskId: string) => Promise>; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + getTaskDiff, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + getTaskDiff = vi.fn(async (_taskId: string) => ({ + diff: 'diff --git a/x b/x', + truncated: false, + })); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const PATH = '/api/mobile/tasks/task-1/diff'; + +describe('GET /api/mobile/tasks/:taskId/diff', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('GET', PATH)).status).toBe(401); + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', PATH, { token })).status).toBe(403); + } + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns the diff for the view-only and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', PATH, { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ diff: 'diff --git a/x b/x', truncated: false }); + } + expect(getTaskDiff).toHaveBeenCalledWith('task-1'); + }); + + it('passes the unsupported flag through for a task with no branch', async () => { + // A 'none' task edits the project folder in place, so the desktop answers + // with an empty diff and this flag rather than an error. + getTaskDiff.mockResolvedValueOnce({ diff: '', truncated: false, unsupported: true }); + const res = await request('GET', PATH, { token: mobileToken }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ diff: '', truncated: false, unsupported: true }); + }); + + it('rejects a malformed escape and prototype keys', async () => { + for (const id of ['%', '__proto__']) { + const res = await request('GET', `/api/mobile/tasks/${id}/diff`, { token: mobileToken }); + expect(res.status).toBe(400); + } + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns 405 for writes', async () => { + expect((await request('POST', PATH, { token: await pair(), body: {} })).status).toBe(405); + }); +}); diff --git a/electron/remote/merge-route.test.ts b/electron/remote/merge-route.test.ts new file mode 100644 index 00000000..372a7ce4 --- /dev/null +++ b/electron/remote/merge-route.test.ts @@ -0,0 +1,275 @@ +// HTTP access control + validation for the phone's merge routes: +// GET /api/mobile/tasks/:taskId/readiness — read-only, like diff/notes +// POST /api/mobile/tasks/:taskId/merge — runs real git, so paired only +// +// The view-only QR token may read readiness but must never be able to merge. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteMergeReadiness } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let getMergeReadiness: Mock<(taskId: string) => Promise>; +let mergeTaskFromMobile: Mock>; + +const READY: RemoteMergeReadiness = { + readiness: { + overall: 'ready', + checks: [{ label: 'Merge safety', status: 'pass', detail: 'Branch is mergeable.' }], + }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', +}; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + getMergeReadiness, + mergeTaskFromMobile, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + getMergeReadiness = vi.fn(async (_taskId: string) => READY); + mergeTaskFromMobile = vi.fn(async (_req) => {}); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const READY_PATH = '/api/mobile/tasks/task-1/readiness'; +const MERGE_PATH = '/api/mobile/tasks/task-1/merge'; + +describe('GET /api/mobile/tasks/:taskId/readiness', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('GET', READY_PATH)).status).toBe(401); + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', READY_PATH, { token })).status).toBe(403); + } + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + // Read-only, so the view-only token may read it — same as diff and notes. + it('returns readiness for the view-only and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', READY_PATH, { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual(READY); + } + expect(getMergeReadiness).toHaveBeenCalledWith('task-1'); + }); + + it('rejects a malformed escape and prototype keys', async () => { + for (const id of ['%', '__proto__', 'constructor', 'prototype']) { + const res = await request('GET', `/api/mobile/tasks/${id}/readiness`, { token: mobileToken }); + expect(res.status).toBe(400); + } + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + it('returns 405 for writes', async () => { + expect((await request('POST', READY_PATH, { token: await pair(), body: {} })).status).toBe(405); + }); + + it('returns 503 when the renderer bridge is unavailable', async () => { + await stop(); + await start({ getMergeReadiness: undefined }); + expect((await request('GET', READY_PATH, { token: mobileToken })).status).toBe(503); + }); + + it('passes a blocked verdict through with canMerge false', async () => { + getMergeReadiness.mockResolvedValueOnce({ + readiness: { + overall: 'blocked', + checks: [ + { label: 'Merge safety', status: 'blocked', detail: 'Worktree has a detached HEAD.' }, + ], + }, + canMerge: false, + baseBranch: 'main', + branchName: 'task/thing', + }); + const res = await request('GET', READY_PATH, { token: mobileToken }); + expect(res.status).toBe(200); + expect((res.json as { canMerge: boolean }).canMerge).toBe(false); + }); + + it('maps a renderer failure to 500', async () => { + getMergeReadiness.mockRejectedValueOnce(new Error('git exploded')); + const res = await request('GET', READY_PATH, { token: mobileToken }); + expect(res.status).toBe(500); + expect((res.json as { error: string }).error).toContain('git exploded'); + }); +}); + +describe('POST /api/mobile/tasks/:taskId/merge', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('POST', MERGE_PATH, { body: {} })).status).toBe(401); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + // Merging runs real git, so the view-only token must be refused. + it('returns 403 for the view-only token', async () => { + expect((await request('POST', MERGE_PATH, { token: mobileToken, body: {} })).status).toBe(403); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('POST', MERGE_PATH, { token, body: {} })).status).toBe(403); + } + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('merges for a paired token and defaults both flags to false', async () => { + const res = await request('POST', MERGE_PATH, { token: await pair(), body: {} }); + expect(res.status).toBe(200); + expect(mergeTaskFromMobile).toHaveBeenCalledWith({ + taskId: 'task-1', + squash: false, + cleanup: false, + }); + }); + + it('forwards explicit squash and cleanup', async () => { + const res = await request('POST', MERGE_PATH, { + token: await pair(), + body: { squash: true, cleanup: true }, + }); + expect(res.status).toBe(200); + expect(mergeTaskFromMobile).toHaveBeenCalledWith({ + taskId: 'task-1', + squash: true, + cleanup: true, + }); + }); + + // Never coerce a truthy value into permission to merge. + it('rejects non-boolean flags rather than coercing them', async () => { + const token = await pair(); + expect((await request('POST', MERGE_PATH, { token, body: { squash: 'yes' } })).status).toBe( + 400, + ); + expect((await request('POST', MERGE_PATH, { token, body: { cleanup: 1 } })).status).toBe(400); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('rejects a malformed escape and prototype keys', async () => { + const token = await pair(); + for (const id of ['%', '__proto__', 'constructor', 'prototype']) { + const res = await request('POST', `/api/mobile/tasks/${id}/merge`, { token, body: {} }); + expect(res.status).toBe(400); + } + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 405 for reads', async () => { + expect((await request('GET', MERGE_PATH, { token: await pair() })).status).toBe(405); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 503 when the renderer bridge is unavailable', async () => { + await stop(); + await start({ mergeTaskFromMobile: undefined }); + expect((await request('POST', MERGE_PATH, { token: await pair(), body: {} })).status).toBe(503); + }); + + it('maps a merge failure to 500 with the reason', async () => { + mergeTaskFromMobile.mockRejectedValueOnce(new Error('Only worktree tasks can be merged')); + const res = await request('POST', MERGE_PATH, { token: await pair(), body: {} }); + expect(res.status).toBe(500); + expect((res.json as { error: string }).error).toContain('Only worktree tasks'); + }); +}); diff --git a/electron/remote/mindmap-route.test.ts b/electron/remote/mindmap-route.test.ts index 187db579..3cb6efa7 100644 --- a/electron/remote/mindmap-route.test.ts +++ b/electron/remote/mindmap-route.test.ts @@ -23,6 +23,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts index 4d086fb7..22a950f2 100644 --- a/electron/remote/notes-route.test.ts +++ b/electron/remote/notes-route.test.ts @@ -11,6 +11,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/pairing.test.ts b/electron/remote/pairing.test.ts index 52774652..55a58393 100644 --- a/electron/remote/pairing.test.ts +++ b/electron/remote/pairing.test.ts @@ -13,6 +13,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/protocol.test.ts b/electron/remote/protocol.test.ts index d3498515..9c515016 100644 --- a/electron/remote/protocol.test.ts +++ b/electron/remote/protocol.test.ts @@ -60,3 +60,28 @@ describe('chat message validation', () => { expect(parseClientMessage(JSON.stringify({ ...action, ...invalid }))).toBeNull(); }); }); + +describe('parseClientMessage view-size', () => { + it('accepts a size and a bare release', () => { + expect( + parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a', cols: 60, rows: 50 })), + ).toEqual({ type: 'view-size', agentId: 'a', cols: 60, rows: 50 }); + expect(parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a' }))).toEqual({ + type: 'view-size', + agentId: 'a', + }); + }); + + it('rejects partial or out-of-range sizes', () => { + for (const size of [ + { cols: 60 }, + { cols: 0, rows: 50 }, + { cols: 60, rows: 501 }, + { cols: 1.5, rows: 2 }, + ]) { + expect( + parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a', ...size })), + ).toBeNull(); + } + }); +}); diff --git a/electron/remote/protocol.ts b/electron/remote/protocol.ts index 3a856eec..9e9da2d3 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -30,8 +30,55 @@ export interface RemoteAgent { attention: RemoteAttentionState; /** Set for the app's built-in chat, which has no terminal to stream. */ kind?: 'chat'; + /** True when the task is collapsed / minimized on the desktop. */ + collapsed?: boolean; } +/** + * The desktop's answer to a phone's close request. `closed: false` means + * nothing was closed because closing would lose work; `warnings` says what. + */ +export type RemoteCloseResult = { closed: true } | { closed: false; warnings: string[] }; + +/** A task's changes against its base branch, as a unified diff; `truncated` when cut short. */ +export interface RemoteTaskDiff { + diff: string; + truncated: boolean; + /** True when the task has no branch of its own, so there is nothing to compare. */ + unsupported?: boolean; +} + +/** One readiness row in a phone's merge dialog; mirrors the desktop's panel. */ +export interface RemoteReadinessCheck { + label: string; + status: 'pass' | 'warning' | 'blocked' | 'checking' | 'neutral'; + detail: string; +} + +/** + * Read-only merge readiness, plus the flags a phone's confirm dialog needs. + * Built by the desktop's own `buildMergeReadiness`, so both surfaces agree on + * what blocks a merge. `canMerge` is false only for a blocker, never a warning. + */ +export interface RemoteMergeReadiness { + readiness: { + overall: 'ready' | 'attention' | 'blocked' | 'checking'; + checks: RemoteReadinessCheck[]; + }; + canMerge: boolean; + baseBranch: string; + branchName: string; +} + +/** Metadata attached to a task's remote agent entry. */ +export type RemoteTaskContext = Pick< + RemoteAgent, + 'projectName' | 'projectColor' | 'agentName' | 'lastLine' +> & { + taskName?: string; + collapsed?: boolean; +}; + /** Conversation actions a paired phone may take on a running chat. */ export const REMOTE_CHAT_ACTIONS = [ 'models', @@ -117,6 +164,18 @@ export interface ResizeCommand { rows: number; } +/** + * A paired phone's terminal size for an agent it is viewing: the PTY takes it + * so full-screen TUIs fill the phone. Without cols/rows it hands the size back + * to the desktop; so does disconnecting. + */ +export interface ViewSizeCommand { + type: 'view-size'; + agentId: string; + cols?: number; + rows?: number; +} + export interface KillCommand { type: 'kill'; agentId: string; @@ -155,6 +214,7 @@ export type ClientMessage = | AuthCommand | InputCommand | ResizeCommand + | ViewSizeCommand | KillCommand | SubscribeCommand | UnsubscribeCommand @@ -199,15 +259,19 @@ export function parseClientMessage(raw: string): ClientMessage | null { ...(typeof msg.prefixKey === 'string' ? { prefixKey: msg.prefixKey } : {}), }; case 'resize': - if (typeof msg.cols !== 'number' || typeof msg.rows !== 'number') return null; - if (!Number.isInteger(msg.cols) || !Number.isInteger(msg.rows)) return null; - if (msg.cols < 1 || msg.cols > 500 || msg.rows < 1 || msg.rows > 500) return null; + if (!isTerminalDimension(msg.cols) || !isTerminalDimension(msg.rows)) return null; return { type: 'resize', agentId: msg.agentId, cols: msg.cols, rows: msg.rows, }; + case 'view-size': { + if (msg.cols === undefined && msg.rows === undefined) + return { type: 'view-size', agentId: msg.agentId }; + if (!isTerminalDimension(msg.cols) || !isTerminalDimension(msg.rows)) return null; + return { type: 'view-size', agentId: msg.agentId, cols: msg.cols, rows: msg.rows }; + } case 'kill': return { type: 'kill', agentId: msg.agentId }; case 'subscribe': @@ -227,6 +291,10 @@ export function parseClientMessage(raw: string): ClientMessage | null { } } +function isTerminalDimension(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 500; +} + function parseChatAction(msg: Record, agentId: string): ChatActionCommand | null { if (typeof msg.requestId !== 'string' || !msg.requestId.length || msg.requestId.length > 80) return null; diff --git a/electron/remote/ring-buffer.test.ts b/electron/remote/ring-buffer.test.ts new file mode 100644 index 00000000..6b7d933c --- /dev/null +++ b/electron/remote/ring-buffer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { RingBuffer } from './ring-buffer.js'; + +describe('RingBuffer', () => { + it('defaults to 2 MB capacity', () => { + const rb = new RingBuffer(); + // Default capacity should be 2 MB + expect(rb.length).toBe(0); + const chunk = Buffer.alloc(1024 * 1024, 'a'); + rb.write(chunk); + expect(rb.length).toBe(1024 * 1024); + }); + + it('reads back appended data in order', () => { + const rb = new RingBuffer(64); + rb.write(Buffer.from('hello ')); + rb.write(Buffer.from('world')); + expect(rb.read().toString('utf8')).toBe('hello world'); + expect(rb.length).toBe(11); + }); + + it('wraps around and retains the latest bytes when capacity is exceeded', () => { + const rb = new RingBuffer(10); + rb.write(Buffer.from('0123456789')); + expect(rb.read().toString('utf8')).toBe('0123456789'); + expect(rb.length).toBe(10); + + rb.write(Buffer.from('abc')); + expect(rb.read().toString('utf8')).toBe('3456789abc'); + expect(rb.length).toBe(10); + }); + + it('handles write larger than capacity by keeping tail', () => { + const rb = new RingBuffer(5); + rb.write(Buffer.from('0123456789')); + expect(rb.read().toString('utf8')).toBe('56789'); + expect(rb.length).toBe(5); + }); + + it('converts to base64 correctly', () => { + const rb = new RingBuffer(32); + rb.write(Buffer.from('test data')); + expect(rb.toBase64()).toBe(Buffer.from('test data').toString('base64')); + }); + + it('clears correctly', () => { + const rb = new RingBuffer(32); + rb.write(Buffer.from('temporary')); + expect(rb.length).toBe(9); + rb.clear(); + expect(rb.length).toBe(0); + expect(rb.read().length).toBe(0); + }); +}); diff --git a/electron/remote/ring-buffer.ts b/electron/remote/ring-buffer.ts index 96381174..11bb1fff 100644 --- a/electron/remote/ring-buffer.ts +++ b/electron/remote/ring-buffer.ts @@ -4,7 +4,7 @@ export class RingBuffer { private pos = 0; private full = false; - constructor(private readonly capacity: number = 64 * 1024) { + constructor(private readonly capacity: number = 2 * 1024 * 1024) { this.buf = Buffer.alloc(capacity); } diff --git a/electron/remote/server-chat.test.ts b/electron/remote/server-chat.test.ts index 95008265..792a2b0e 100644 --- a/electron/remote/server-chat.test.ts +++ b/electron/remote/server-chat.test.ts @@ -12,6 +12,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => ['a1', 'a2']), diff --git a/electron/remote/server-helpers.test.ts b/electron/remote/server-helpers.test.ts index cddb3857..03ec7471 100644 --- a/electron/remote/server-helpers.test.ts +++ b/electron/remote/server-helpers.test.ts @@ -9,6 +9,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), @@ -22,7 +23,8 @@ vi.mock('./protocol.js', () => ({ parseClientMessage: vi.fn(() => null), })); -const { requireOwnedTask, readCoordinatorBody } = await import('./server.js'); +const pty = await import('../ipc/pty.js'); +const { requireOwnedTask, readCoordinatorBody, buildAgentList } = await import('./server.js'); type FakeRequest = EventEmitter & { destroy: ReturnType }; @@ -102,3 +104,95 @@ describe('readCoordinatorBody', () => { expect(req.destroy).not.toHaveBeenCalled(); }); }); + +describe('buildAgentList', () => { + it('includes active agents and incorporates collapsed tasks', () => { + vi.mocked(pty.getActiveAgentIds).mockReturnValue(['agent-1']); + vi.mocked(pty.getAgentMeta).mockImplementation((id) => + id === 'agent-1' + ? ({ + agentId: 'agent-1', + taskId: 'task-1', + isShell: false, + command: 'node', + args: [], + cols: 80, + rows: 24, + createdAt: Date.now(), + } as ReturnType) + : null, + ); + + const getTaskName = (id: string) => (id === 'task-1' ? 'Task 1' : 'Task 2'); + const getAgentStatus = () => ({ + status: 'running' as const, + exitCode: null, + lastLine: 'Working...', + }); + const getTaskAttention = () => 'active' as const; + const getTaskContext = (id: string) => + id === 'task-2' + ? { + projectName: 'Proj', + projectColor: '#fff', + agentName: 'AgentDef', + lastLine: 'Last known line', + taskName: 'Task 2 Name', + collapsed: true, + } + : undefined; + const getCollapsedTaskIds = () => ['task-2']; + + const agents = buildAgentList( + getTaskName, + getAgentStatus, + getTaskAttention, + getTaskContext, + getCollapsedTaskIds, + ); + + expect(agents).toHaveLength(2); + expect(agents[0]).toMatchObject({ + agentId: 'agent-1', + taskId: 'task-1', + taskName: 'Task 1', + status: 'running', + }); + expect(agents[1]).toMatchObject({ + agentId: 'collapsed:task-2', + taskId: 'task-2', + taskName: 'Task 2 Name', + projectName: 'Proj', + status: 'exited', + collapsed: true, + lastLine: 'Last known line', + }); + }); + + it('marks existing agents as collapsed if present in getCollapsedTaskIds', () => { + vi.mocked(pty.getActiveAgentIds).mockReturnValue(['agent-1']); + vi.mocked(pty.getAgentMeta).mockImplementation( + () => + ({ + agentId: 'agent-1', + taskId: 'task-1', + isShell: false, + }) as ReturnType, + ); + + const agents = buildAgentList( + () => 'Task 1', + () => ({ status: 'running', exitCode: null, lastLine: '' }), + () => 'idle', + undefined, + () => ['task-1'], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + agentId: 'agent-1', + taskId: 'task-1', + collapsed: true, + }); + }); +}); diff --git a/electron/remote/server-ws.test.ts b/electron/remote/server-ws.test.ts index dd423702..41c17158 100644 --- a/electron/remote/server-ws.test.ts +++ b/electron/remote/server-ws.test.ts @@ -14,8 +14,10 @@ import { join } from 'node:path'; vi.mock('../ipc/pty.js', () => ({ writeToAgent: vi.fn(), resizeAgent: vi.fn(), + setAgentRemoteSize: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), @@ -135,6 +137,14 @@ describe('mobile token over WebSocket', () => { expect(pty.resizeAgent).not.toHaveBeenCalled(); }); + it('rejects view-size with 4003 and leaves the PTY size alone', async () => { + const ws = await connectAndAuth(mobileToken); + const closed = waitForClose(ws); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + expect(await closed).toBe(4003); + expect(pty.setAgentRemoteSize).not.toHaveBeenCalled(); + }); + it('rejects kill with 4003 and does not kill the agent', async () => { const ws = await connectAndAuth(mobileToken); const closed = waitForClose(ws); @@ -144,6 +154,65 @@ describe('mobile token over WebSocket', () => { }); }); +describe('subscribe snapshot', () => { + it('sends the rendered snapshot when the PTY has one', async () => { + vi.mocked(pty.subscribeToAgentRendered).mockImplementation((_id, onSnapshot) => { + onSnapshot({ data: 'cmVuZGVyZWQ=', cols: 60, rows: 50 }); + return vi.fn(); + }); + const ws = await connectAndAuth(mobileToken); + const scrollback = new Promise((resolve) => + ws.on('message', (raw) => { + const msg = JSON.parse(String(raw)) as { type: string }; + if (msg.type === 'scrollback') resolve(msg); + }), + ); + ws.send(JSON.stringify({ type: 'subscribe', agentId: 'agent-1' })); + expect(await scrollback).toEqual({ + type: 'scrollback', + agentId: 'agent-1', + data: 'cmVuZGVyZWQ=', + cols: 60, + rows: 50, + }); + expect(pty.getAgentScrollback).not.toHaveBeenCalled(); + ws.close(); + }); +}); + +describe('paired phone view size', () => { + it('sizes the PTY and hands it back when the phone disconnects', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + await vi.waitFor(() => + expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', { cols: 60, rows: 50 }), + ); + ws.close(); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', null)); + }); + + it('hands the size back on an explicit release', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1' })); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', null)); + ws.close(); + }); + + it('ignores a release from a phone that does not own the size', async () => { + const owner = await connectAndAuth(await pair()); + const other = await connectAndAuth(await pair()); + owner.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledTimes(1)); + other.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1' })); + other.close(); + // Give the ignored release a chance to arrive before checking. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(pty.setAgentRemoteSize).toHaveBeenCalledTimes(1); + owner.close(); + }); +}); + describe('paired token over WebSocket', () => { it('forwards input to the agent PTY', async () => { const ws = await connectAndAuth(await pair()); diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 6d95159f..d526a6b5 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -11,8 +11,10 @@ import { warn } from '../log.js'; import { writeToAgent, resizeAgent, + setAgentRemoteSize, killAgent, subscribeToAgent, + subscribeToAgentRendered, unsubscribeFromAgent, getAgentScrollback, getActiveAgentIds, @@ -26,6 +28,10 @@ import { type ServerMessage, type RemoteAgent, type RemoteAttentionState, + type RemoteTaskContext, + type RemoteCloseResult, + type RemoteMergeReadiness, + type RemoteTaskDiff, } from './protocol.js'; import { createChatSubscriptions, @@ -311,7 +317,7 @@ function getNetworkIps(): { wifi: string | null; tailscale: string | null } { } /** Build the agent list, deduplicated by taskId (keeps main agent per task). */ -function buildAgentList( +export function buildAgentList( getTaskName: (taskId: string) => string, getAgentStatus: (agentId: string) => { status: 'running' | 'exited'; @@ -319,9 +325,8 @@ function buildAgentList( lastLine: string; }, getTaskAttention: (taskId: string) => RemoteAttentionState, - getTaskContext?: ( - taskId: string, - ) => Pick | undefined, + getTaskContext?: (taskId: string) => RemoteTaskContext | undefined, + getCollapsedTaskIds?: () => string[], ): RemoteAgent[] { const byTask = new Map(); for (const agentId of getActiveAgentIds()) { @@ -346,6 +351,29 @@ function buildAgentList( byTask.set(meta.taskId, agent); } } + + if (getCollapsedTaskIds) { + for (const taskId of getCollapsedTaskIds()) { + const existing = byTask.get(taskId); + if (existing) { + existing.collapsed = true; + } else { + const ctx = getTaskContext?.(taskId); + byTask.set(taskId, { + agentId: `collapsed:${taskId}`, + taskId, + taskName: ctx?.taskName || getTaskName(taskId), + status: 'exited', + exitCode: null, + lastLine: ctx?.lastLine ?? '', + attention: getTaskAttention(taskId), + collapsed: true, + ...ctx, + }); + } + } + } + return Array.from(byTask.values()); } @@ -932,13 +960,24 @@ export function startRemoteServer(opts: { publishTour?: (taskId: string, payload: AgentTourPayload) => Promise; /** Read a task's notes (renderer-backed). */ getTaskNotes?: (taskId: string) => Promise; + /** Read merge readiness for the phone's merge dialog (renderer-backed). */ + getMergeReadiness?: (taskId: string) => Promise; + /** Merge a task on behalf of a paired phone (renderer-backed). */ + mergeTaskFromMobile?: (req: { + taskId: string; + squash: boolean; + cleanup: boolean; + }) => Promise; /** Persist a task's notes (renderer-backed). */ setTaskNotes?: (taskId: string, notes: string) => Promise; + /** Close a task (renderer-backed); unless `force`, refuses when work would be lost. */ + closeTaskFromMobile?: (taskId: string, force: boolean) => Promise; + /** A task's diff against its base branch (renderer-backed). */ + getTaskDiff?: (taskId: string) => Promise; /** Renderer-derived task attention state (needs input, working, ready, …). */ getTaskAttention?: (taskId: string) => RemoteAttentionState; - getTaskContext?: ( - taskId: string, - ) => Pick | undefined; + getTaskContext?: (taskId: string) => RemoteTaskContext | undefined; + getCollapsedTaskIds?: () => string[]; /** The desktop's built-in chats; without it phones only see terminals. */ chats?: RemoteChatSource; }): Promise { @@ -949,7 +988,13 @@ export function startRemoteServer(opts: { opts.getTaskAttention ?? (() => 'idle'); const agentList = (): RemoteAgent[] => withChatAgents( - buildAgentList(opts.getTaskName, opts.getAgentStatus, getTaskAttention, opts.getTaskContext), + buildAgentList( + opts.getTaskName, + opts.getAgentStatus, + getTaskAttention, + opts.getTaskContext, + opts.getCollapsedTaskIds, + ), opts.chats?.list() ?? [], (taskId) => ({ taskName: opts.getTaskName(taskId), @@ -1353,6 +1398,66 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Task diff (read: mobile + paired) --- + // The same changes the desktop's diff view shows. Read-only, like notes: + // the view-only token already streams the terminals that made them. + const diffMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/diff$/); + if (diffMatch) { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + const getTaskDiff = opts.getTaskDiff; + if (!getTaskDiff) return jsonEnd(503, { error: 'diff unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(diffMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + getTaskDiff(taskId) + .then((diff) => jsonEnd(200, diff)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + + // --- Paired-mobile task close --- + // Closing stops the task's agents and removes its worktree, so it needs + // the paired token. Without `force` the desktop refuses when work would + // be lost and answers 409 with its Close Task dialog's warnings. + const closeMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/close$/); + if (closeMatch) { + if (tokenClass !== 'paired') return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'POST') return jsonEnd(405, { error: 'method not allowed' }); + const closeTask = opts.closeTaskFromMobile; + if (!closeTask) return jsonEnd(503, { error: 'task close unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(closeMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + readJsonBody(req) + .then((body) => { + if (body.force !== undefined && typeof body.force !== 'boolean') + return jsonEnd(400, { error: 'force must be a boolean' }); + closeTask(taskId, body.force === true) + .then((result) => + result.closed + ? jsonEnd(200, { ok: true }) + : jsonEnd(409, { error: 'closing would lose work', warnings: result.warnings }), + ) + .catch((err) => jsonEnd(500, { error: String(err) })); + }) + .catch(() => jsonEnd(400, { error: 'bad request' })); + return; + } + // --- Task notes (read: mobile + paired; write: paired) --- // The notes textarea shown on the desktop task panel. The QR-code mobile // token may read notes; writing them (text that lands in the desktop UI @@ -1413,6 +1518,69 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Merge readiness (read: mobile + paired) --- + // Read-only, so it follows diff/notes. Phones render these checks verbatim + // before merging; the merge itself is the paired-only write below. + const readinessMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/readiness$/); + if (readinessMatch) { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + const getMergeReadiness = opts.getMergeReadiness; + if (!getMergeReadiness) return jsonEnd(503, { error: 'readiness unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(readinessMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + getMergeReadiness(taskId) + .then((result) => jsonEnd(200, result)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + + // --- Paired-mobile task merge --- + // Merging runs real git against the base branch, so it needs the paired + // token. `cleanup` is opt-in and defaults off, so a tap never deletes a + // worktree or branch the way the desktop checkbox explicitly allows. + const mergeMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/merge$/); + if (mergeMatch) { + if (tokenClass !== 'paired') return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'POST') return jsonEnd(405, { error: 'method not allowed' }); + const mergeTaskFromMobile = opts.mergeTaskFromMobile; + if (!mergeTaskFromMobile) return jsonEnd(503, { error: 'task merge unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(mergeMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + readJsonBody(req) + .then((body) => { + if (body.squash !== undefined && typeof body.squash !== 'boolean') + return jsonEnd(400, { error: 'squash must be a boolean' }); + if (body.cleanup !== undefined && typeof body.cleanup !== 'boolean') + return jsonEnd(400, { error: 'cleanup must be a boolean' }); + return mergeTaskFromMobile({ + taskId, + squash: body.squash === true, + cleanup: body.cleanup === true, + }).then( + () => jsonEnd(200, { ok: true }), + (err: unknown) => jsonEnd(500, { error: String(err) }), + ); + }) + .catch(() => jsonEnd(400, { error: 'bad request' })); + return; + } + // Coordinator agents reach their own tasks and canvases, never other agents' terminals. if ( coordinatorAgent && @@ -1664,6 +1832,8 @@ export function startRemoteServer(opts: { }); const clientSubs = new WeakMap void>>(); + // Which phone currently sizes each agent's PTY (see ViewSizeCommand). + const viewSizeOwners = new Map(); const authenticatedClients = new Set(); const clientTokenTypes = new Map(); const pendingSubmissions = new Map>(); @@ -1788,6 +1958,12 @@ export function startRemoteServer(opts: { ws.close(4003, 'Pairing required'); return; } + // A viewing phone may size the terminal once paired: it changes what the + // desktop pane shows, and typing already needs the same trust. + if (msg.type === 'view-size' && tokenType !== 'coordinator' && tokenType !== 'paired') { + ws.close(4003, 'Pairing required'); + return; + } if ((msg.type === 'resize' || msg.type === 'kill') && tokenType !== 'coordinator') { ws.close(4003, 'Forbidden'); return; @@ -1903,6 +2079,23 @@ export function startRemoteServer(opts: { } break; + case 'view-size': { + const size = + msg.cols !== undefined && msg.rows !== undefined + ? { cols: msg.cols, rows: msg.rows } + : null; + // The last phone to size an agent owns it; only the owner hands it back. + if (!size && viewSizeOwners.get(msg.agentId) !== ws) break; + try { + setAgentRemoteSize(msg.agentId, size); + if (size) viewSizeOwners.set(msg.agentId, ws); + else viewSizeOwners.delete(msg.agentId); + } catch { + viewSizeOwners.delete(msg.agentId); + } + break; + } + case 'kill': try { killAgent(msg.agentId); @@ -1915,19 +2108,23 @@ export function startRemoteServer(opts: { const subs = clientSubs.get(ws); if (subs?.has(msg.agentId)) break; - const scrollback = getAgentScrollback(msg.agentId); - if (scrollback) { + const sendScrollback = (data: string, cols: number, rows: number) => { + if (ws.readyState !== WebSocket.OPEN) return; ws.send( JSON.stringify({ type: 'scrollback', agentId: msg.agentId, - data: scrollback, - cols: getAgentCols(msg.agentId), - rows: getAgentRows(msg.agentId), + data, + cols, + rows, } satisfies ServerMessage), ); - } - + }; + const sendRawScrollback = () => { + const scrollback = getAgentScrollback(msg.agentId); + if (scrollback) + sendScrollback(scrollback, getAgentCols(msg.agentId), getAgentRows(msg.agentId)); + }; const cb = (encoded: string) => { if (ws.readyState === WebSocket.OPEN) { ws.send( @@ -1939,8 +2136,22 @@ export function startRemoteServer(opts: { ); } }; - if (subscribeToAgent(msg.agentId, cb)) { - subs?.set(msg.agentId, cb); + // Phones get the rendered screen and history; a live agent's raw + // replay can be nothing but repaints of its last screen. + const subscriber = subscribeToAgentRendered( + msg.agentId, + (snapshot) => { + if (snapshot) sendScrollback(snapshot.data, snapshot.cols, snapshot.rows); + else sendRawScrollback(); + }, + cb, + ); + if (subscriber) { + subs?.set(msg.agentId, subscriber); + } else { + // Not a live PTY session: replay what is left, as before. + sendRawScrollback(); + if (subscribeToAgent(msg.agentId, cb)) subs?.set(msg.agentId, cb); } break; } @@ -1958,6 +2169,15 @@ export function startRemoteServer(opts: { }); ws.on('close', () => { + for (const [agentId, owner] of viewSizeOwners) { + if (owner !== ws) continue; + viewSizeOwners.delete(agentId); + try { + setAgentRemoteSize(agentId, null); + } catch { + /* agent gone */ + } + } authenticatedClients.delete(ws); clientTokenTypes.delete(ws); const timer = authTimers.get(ws); diff --git a/electron/remote/session-route.test.ts b/electron/remote/session-route.test.ts index bb43d9a2..0498caf3 100644 --- a/electron/remote/session-route.test.ts +++ b/electron/remote/session-route.test.ts @@ -9,6 +9,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/eslint.config.js b/eslint.config.js index f6f3e4da..b6d12f10 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,8 @@ export default [ 'dist-electron/**', 'dist-remote/**', 'release/**', + 'android/.gradle/**', + 'android/**/build/**', 'node_modules/**', '.worktrees/**', '.claude/**', @@ -103,6 +105,20 @@ export default [ }, }, + // The look generator is a command-line tool: it reports what it wrote or why it + // refused, which is the whole output of a --check run in CI. + { + files: ['scripts/generate-android-looks.mjs'], + languageOptions: { + globals: { + console: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + // CJS files (electron/preload.cjs): allow require(), CommonJS globals { files: ['**/*.cjs'], diff --git a/package-lock.json b/package-lock.json index b34afa1e..59aeb7df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.12.0-beta.195", "@xterm/addon-search": "^0.17.0-beta.195", + "@xterm/addon-serialize": "^0.15.0-beta.195", "@xterm/addon-web-links": "^0.13.0-beta.195", "@xterm/addon-webgl": "^0.20.0-beta.194", "@xterm/headless": "^6.1.0-beta.195", @@ -4497,6 +4498,15 @@ "@xterm/xterm": "^6.1.0-beta.195" } }, + "node_modules/@xterm/addon-serialize": { + "version": "0.15.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.195.tgz", + "integrity": "sha512-BKlEQ5cao0J8sLvtFU/kmc4LAkAS+2JkblVIEu/FFHWUsuEaA4yZz4BtZrWBhql2JRfOq62hZFrqahZ0ViuZgA==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.195" + } + }, "node_modules/@xterm/addon-web-links": { "version": "0.13.0-beta.195", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.13.0-beta.195.tgz", diff --git a/package.json b/package.json index 11656d88..90429858 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint:secrets": "command -v gitleaks >/dev/null 2>&1 && gitleaks detect --config .gitleaks.toml || (echo 'gitleaks not installed (brew install gitleaks)' >&2; exit 1)", "format": "prettier --write .", "format:check": "prettier --check .", + "generate:android-looks": "node scripts/generate-android-looks.mjs", + "check:android-looks": "node scripts/generate-android-looks.mjs --check", "test": "npm run test:unit && npm run test:client", "test:unit": "vitest run", "test:client": "vitest run --config vitest.client.config.ts", @@ -59,6 +61,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.12.0-beta.195", "@xterm/addon-search": "^0.17.0-beta.195", + "@xterm/addon-serialize": "^0.15.0-beta.195", "@xterm/addon-web-links": "^0.13.0-beta.195", "@xterm/addon-webgl": "^0.20.0-beta.194", "@xterm/headless": "^6.1.0-beta.195", diff --git a/scripts/generate-android-looks.mjs b/scripts/generate-android-looks.mjs new file mode 100644 index 00000000..19c98266 --- /dev/null +++ b/scripts/generate-android-looks.mjs @@ -0,0 +1,514 @@ +/** + * Generates the phone app's look palettes from the desktop's own sources, so the + * two never drift apart by hand: + * + * src/lib/look.ts -> preset ids, labels, descriptions and light/dark tone + * src/styles.css -> the resolved colors, radii and shape scale + * + * The desktop assigns each `html[data-look='']` rule only the variables it + * changes; everything else falls through to `:root`. This resolves that cascade + * per preset so the phone gets a complete palette, then writes it to + * android/app/src/main/java/com/parallelcode/phone/LookPalettes.kt. + * + * Run: node scripts/generate-android-looks.mjs [--check] + * + * `--check` verifies the generated file is current without writing, which is how + * LookPalettesTest keeps the two apps honest. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const STYLES = join(root, 'src/styles.css'); +const LOOK_TS = join(root, 'src/lib/look.ts'); +const THEME_TS = join(root, 'src/lib/theme.ts'); +const OUT = join(root, 'android/app/src/main/java/com/parallelcode/phone/LookPalettes.kt'); + +/* ------------------------------------------------------------------ CSS ---- */ + +/** Drops `/* ... *\/` comments while preserving line numbers for error messages. */ +function stripComments(css) { + return css.replace(/\/\*[\s\S]*?\*\//g, (m) => '\n'.repeat((m.match(/\n/g) || []).length)); +} + +/** + * Returns every rule block that declares custom properties, in source order. + * `selectors` is the comma-split selector list, still unparsed. + */ +function ruleBlocks(css) { + const out = []; + const re = /([^{}]+)\{/g; + let m; + while ((m = re.exec(css)) !== null) { + const start = m.index + m[0].length; + let depth = 1; + let i = start; + while (depth > 0) { + if (css[i] === '{') depth += 1; + else if (css[i] === '}') depth -= 1; + i += 1; + } + const body = css.slice(start, i - 1); + const props = {}; + for (const p of body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) { + props[p[1]] = p[2].replace(/\s+/g, ' ').trim(); + } + if (Object.keys(props).length > 0) { + out.push({ + selectors: m[1].split(',').map((s) => s.trim().replace(/\s+/g, ' ')), + props, + line: css.slice(0, m.index).split('\n').length, + }); + } + re.lastIndex = i; + } + return out; +} + +/** + * True when a selector part is *only* a look matcher, e.g. + * `html[data-look='noir']`, `[data-look^='obsidian']` or `html:is([data-look='a'])`. + * Structural selectors such as `html[data-look='noir'] .icon-btn` are rejected: + * their custom properties style a specific component, not the palette. + */ +function lookMatcher(selector) { + let s = selector.trim(); + const is = /^html:is\(([\s\S]*)\)$/.exec(s); + if (is) { + const parts = is[1].split(',').map((p) => p.trim()); + const matchers = parts.map(lookMatcher); + return matchers.every(Boolean) ? (id) => matchers.some((f) => f(id)) : null; + } + let m = /^(?:html)?\[data-look\^='([a-z-]+)'\]$/.exec(s); + if (m) { + const prefix = m[1]; + return (id) => id.startsWith(prefix); + } + m = /^(?:html)?\[data-look='([a-z-]+)'\]$/.exec(s); + if (m) { + const exact = m[1]; + return (id) => id === exact; + } + return null; +} + +function selectorMatcher(selectors) { + const matchers = selectors.map(lookMatcher); + if (matchers.some((f) => f === null)) return null; + return (id) => matchers.some((f) => f(id)); +} + +/* --------------------------------------------------------------- colors ---- */ + +const HEX = /^#([0-9a-f]{6})$/i; +const HEX_SHORT = /^#([0-9a-f]{3})$/i; +const RGB = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i; + +/** Parses `#rgb`, `#rrggbb`, `rgb()` and `rgba()` into an `0xAARRGGBB` int. */ +function parseColor(value) { + const v = value.trim(); + let m = HEX.exec(v); + if (m) return Number.parseInt(`ff${m[1]}`, 16) >>> 0; + m = HEX_SHORT.exec(v); + if (m) { + const [r, g, b] = m[1].split(''); + return Number.parseInt(`ff${r}${r}${g}${g}${b}${b}`, 16) >>> 0; + } + m = RGB.exec(v); + if (m) { + const alpha = m[4] === undefined ? 1 : Number.parseFloat(m[4]); + const a = Math.round(alpha * 255); + return ((a << 24) | (Number(m[1]) << 16) | (Number(m[2]) << 8) | Number(m[3])) >>> 0; + } + return null; +} + +const GRADIENT = /^(?:repeating-)?(?:radial|linear|conic)-gradient\(/; + +/** + * Flattens a gradient to one representative color: its middle stop, which is the + * hue that covers most of the surface. The phone draws flat backgrounds, so a + * gradient cannot be reproduced exactly; the middle stop is the closest single + * color and keeps a theme's identity readable. + */ +function flatten(value) { + const v = value.trim(); + if (!GRADIENT.test(v)) return v; + const stops = v.slice(v.indexOf('(') + 1).match(/(#[0-9a-f]{3,8}|rgba?\([^)]*\))/gi); + if (!stops || stops.length === 0) { + throw new Error(`gradient with no color stops: ${value}`); + } + return stops[Math.floor((stops.length - 1) / 2)]; +} + +/** Resolves one custom property to an `0xAARRGGBB` literal body for Kotlin. */ +function colorLiteral(value, where) { + const flat = flatten(value); + const argb = parseColor(flat); + if (argb === null) throw new Error(`cannot parse color "${value}" (${where})`); + const hex = argb.toString(16).padStart(8, '0'); + return `Color(0x${hex.toUpperCase()})`; +} + +/* --------------------------------------------------------------- shapes ---- */ + +/** + * Converts a CSS length to a Kotlin dp literal. Radii are authored in `px`, + * which the desktop also uses as its density-independent base, so px maps 1:1 + * to dp. A unitless `0` is the one other form CSS allows for zero. + */ +function dpLiteral(value, where) { + const m = /^(-?[\d.]+)(?:px)?$/.exec(value.trim()); + if (!m) throw new Error(`cannot parse length "${value}" (${where})`); + const n = Number(m[1]); + return n === 0 ? '0.dp' : `${Number(n.toFixed(2))}.dp`; +} + +/* ------------------------------------------------------------ look.ts ------ */ + +/** Reads LOOK_PRESETS out of look.ts so ids, labels, tone and order all match. */ +function readPresets(ts) { + const start = ts.indexOf('export const LOOK_PRESETS'); + if (start < 0) throw new Error('LOOK_PRESETS not found in src/lib/look.ts'); + const end = ts.indexOf('\n];', start); + const body = ts.slice(start, end); + const presets = []; + const re = + /\{\s*id:\s*'([a-z-]+)',\s*label:\s*'([^']*)',\s*description:\s*'([^']*)',\s*tone:\s*'(light|dark)',\s*\}/g; + let m; + while ((m = re.exec(body)) !== null) { + presets.push({ id: m[1], label: m[2], description: m[3], tone: m[4] }); + } + if (presets.length === 0) throw new Error('no presets parsed from src/lib/look.ts'); + return presets; +} + +/* --------------------------------------------------------- terminal ANSI --- */ + +/** + * The desktop's terminal themes, in the order `getTerminalTheme` checks them: + * any light preset, then Obsidian, Noir and Islands Dark. Every other dark preset + * falls through to xterm's own defaults, which the phone has no equivalent of, so + * those looks reuse [TERMINAL_NOIR] (a muted dark set) rather than xterm's + * saturated defaults. Read from src/lib/theme.ts so the four named sets match. + */ +const TERMINAL_THEMES = [ + { id: 'light', label: 'Light', theme: 'LIGHT_TERMINAL_THEME', forTone: 'light' }, + { id: 'obsidian', label: 'Obsidian', theme: 'OBSIDIAN_TERMINAL_THEME', forTone: 'dark' }, + { id: 'noir', label: 'Noir', theme: 'NOIR_TERMINAL_THEME', forTone: 'dark' }, + { + id: 'islands-dark', + label: 'Islands Dark', + theme: 'ISLANDS_DARK_TERMINAL_THEME', + forTone: 'dark', + }, +]; + +/** + * A 0xAARRGGBB literal for Kotlin's `Int` fields. Hex literals above Int.MAX_VALUE + * are Longs in Kotlin, so terminal colors (which are always opaque and so exceed + * it) are written as signed decimals instead. + */ +const intLiteral = (argb) => String(argb > 0x7fffffff ? argb - 0x100000000 : argb); + +/** The xterm ANSI slot order, matching `ansi[c]` lookups in TerminalStyle.kt. */ +const ANSI_SLOTS = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite', +]; + +function readTerminalThemes(ts) { + return TERMINAL_THEMES.map(({ id, label, theme, forTone }) => { + const match = new RegExp(`const ${theme}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const`).exec(ts); + if (!match) throw new Error(`${theme} not found in src/lib/theme.ts`); + const colors = Object.fromEntries( + [...match[1].matchAll(/(\w+):\s*'(#[0-9a-fA-F]{6})'/g)].map((m) => [m[1], m[2]]), + ); + const argb = (name) => { + if (!(name in colors)) throw new Error(`${theme} is missing ${name}`); + return intLiteral(parseColor(colors[name])); + }; + return { id, label, forTone, foreground: argb('foreground'), ansi: ANSI_SLOTS.map(argb) }; + }); +} + +/** + * Which terminal theme a preset uses, following `getTerminalTheme`: light presets + * share one set, then the three dark looks that have their own, and everything + * else falls back to Noir. + */ +function terminalThemeIdFor(preset, byId) { + // `preset.tone` is 'dark' | 'light' as read from look.ts. + if (preset.tone !== 'dark') return 'light'; + const own = byId.get(preset.id); + return own ? own.id : 'noir'; +} + +/* ---------------------------------------------------------------- main ----- */ + +const css = stripComments(readFileSync(STYLES, 'utf8')); +const presets = readPresets(readFileSync(LOOK_TS, 'utf8')); +const blocks = ruleBlocks(css); + +if (!/^:root$/m.test(blocks[0].selectors[0])) { + throw new Error( + `expected the first custom-property block to be :root, got ${blocks[0].selectors[0]}`, + ); +} +const base = blocks[0].props; + +/** Applies the cascade for one preset id. */ +function resolve(presetId) { + const vars = { ...base }; + for (const block of blocks.slice(1)) { + const matches = selectorMatcher(block.selectors); + if (matches && matches(presetId)) Object.assign(vars, block.props); + } + return vars; +} + +const need = (vars, name, presetId) => { + if (!(name in vars)) throw new Error(`preset ${presetId}: missing ${name}`); + return vars[name]; +}; + +/** Escapes a Kotlin string literal's body. */ +const kotlinString = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + +const terminalThemes = readTerminalThemes(readFileSync(THEME_TS, 'utf8')); +const terminalThemesById = new Map(terminalThemes.map((t) => [t.id, t])); + +const entries = presets.map((preset) => { + const v = resolve(preset.id); + const at = (name) => colorLiteral(need(v, name, preset.id), `${preset.id} ${name}`); + return { + preset, + terminalThemeId: terminalThemeIdFor(preset, terminalThemesById), + colors: { + bg: at('--bg'), + bgElevated: at('--bg-elevated'), + bgInput: at('--bg-input'), + bgHover: at('--bg-hover'), + bgSelected: at('--bg-selected'), + border: at('--border'), + borderSubtle: at('--border-subtle'), + borderFocus: at('--border-focus'), + fg: at('--fg'), + fgMuted: at('--fg-muted'), + fgSubtle: at('--fg-subtle'), + accent: at('--accent'), + accentHover: at('--accent-hover'), + accentText: at('--accent-text'), + link: at('--link'), + success: at('--success'), + error: at('--error'), + warning: at('--warning'), + review: at('--review'), + info: at('--info'), + islandBg: at('--island-bg'), + islandBorder: at('--island-border'), + containerBg: at('--task-container-bg'), + panelBg: at('--task-panel-bg'), + diffAddBg: at('--diff-add-bg'), + diffRemoveBg: at('--diff-remove-bg'), + }, + shapes: { + radiusXs: dpLiteral(need(v, '--radius-xs', preset.id), `${preset.id} --radius-xs`), + radiusSm: dpLiteral(need(v, '--radius-sm', preset.id), `${preset.id} --radius-sm`), + radiusMd: dpLiteral(need(v, '--radius-md', preset.id), `${preset.id} --radius-md`), + radiusLg: dpLiteral(need(v, '--radius-lg', preset.id), `${preset.id} --radius-lg`), + radiusIsland: dpLiteral( + need(v, '--island-radius', preset.id), + `${preset.id} --island-radius`, + ), + }, + }; +}); + +// Keep the field order in one place so the data class and the literals agree. +const COLOR_FIELDS = Object.keys(entries[0].colors); +const SHAPE_FIELDS = Object.keys(entries[0].shapes); + +const pascal = (id) => + id + .split('-') + .map((p) => p[0].toUpperCase() + p.slice(1)) + .join(''); + +function render() { + const lines = []; + lines.push('package com.parallelcode.phone'); + lines.push(''); + lines.push('// GENERATED FILE - DO NOT EDIT.'); + lines.push('//'); + lines.push('// Regenerate with: node scripts/generate-android-looks.mjs'); + lines.push('// Source of truth: src/lib/look.ts (ids, labels, descriptions, tone) and'); + lines.push('// src/styles.css (colors and radii), matching the desktop app.'); + lines.push('//'); + lines.push('// The desktop themes set only the variables they change and inherit the rest'); + lines.push('// from :root; every palette below is that cascade fully resolved, so the phone'); + lines.push('// has no fallbacks of its own. Gradients are flattened to their middle stop'); + lines.push('// because the phone draws flat surfaces; everything else is the exact value.'); + lines.push(''); + lines.push('import androidx.compose.runtime.Immutable'); + lines.push('import androidx.compose.ui.graphics.Color'); + lines.push('import androidx.compose.ui.unit.Dp'); + lines.push('import androidx.compose.ui.unit.dp'); + lines.push(''); + + lines.push('/**'); + lines.push(' * One desktop look preset: every color and radius the phone draws with.'); + lines.push(' *'); + lines.push(' * Read these through [LookPresets] rather than constructing one.'); + lines.push(' */'); + lines.push('@Immutable'); + lines.push('data class LookPalette('); + lines.push(' val id: String,'); + lines.push(' val label: String,'); + lines.push(' val description: String,'); + lines.push(" /** True for the desktop's light presets. */"); + lines.push(' val dark: Boolean,'); + lines.push(" // Surfaces and state, from the desktop's --bg family."); + lines.push(' val bg: Color,'); + lines.push(' val bgElevated: Color,'); + lines.push(' val bgInput: Color,'); + lines.push(' val bgHover: Color,'); + lines.push(' val bgSelected: Color,'); + lines.push(' // Borders.'); + lines.push(' val border: Color,'); + lines.push(' val borderSubtle: Color,'); + lines.push(' val borderFocus: Color,'); + lines.push(' // Text.'); + lines.push(' val fg: Color,'); + lines.push(' val fgMuted: Color,'); + lines.push(' val fgSubtle: Color,'); + lines.push(' // Accent family.'); + lines.push(' val accent: Color,'); + lines.push(' val accentHover: Color,'); + lines.push(' val accentText: Color,'); + lines.push(' val link: Color,'); + lines.push(' // Status hues: success, error and warning plus the secondary'); + lines.push(' // review and info hues the desktop keeps off the accent.'); + lines.push(' val success: Color,'); + lines.push(' val error: Color,'); + lines.push(' val warning: Color,'); + lines.push(' val review: Color,'); + lines.push(' val info: Color,'); + lines.push(' // Panels.'); + lines.push(' val islandBg: Color,'); + lines.push(' val islandBorder: Color,'); + lines.push(' val containerBg: Color,'); + lines.push(' val panelBg: Color,'); + lines.push(' // Diff line tints.'); + lines.push(' val diffAddBg: Color,'); + lines.push(' val diffRemoveBg: Color,'); + lines.push(" // The desktop's corner radius scale, in dp."); + lines.push(' val radiusXs: Dp,'); + lines.push(' val radiusSm: Dp,'); + lines.push(' val radiusMd: Dp,'); + lines.push(' val radiusLg: Dp,'); + lines.push(' /** Corner radius of an island or card, `--island-radius`. */'); + lines.push(' val radiusIsland: Dp,'); + lines.push(' /**'); + lines.push(' * Id of the [TERMINAL_THEMES] entry this look draws terminals with, so agent'); + lines.push(' * output follows the look the way it does on the desktop.'); + lines.push(' */'); + lines.push(' val terminalThemeId: String,'); + lines.push(')'); + lines.push(''); + + lines.push('/**'); + lines.push(" * One of the desktop's terminal color sets: a default text color and the 16 ANSI"); + lines.push(' * colors, in the order `ansi[c]` is indexed in TerminalStyle.kt.'); + lines.push(' */'); + lines.push('@Immutable'); + lines.push('data class TerminalTheme('); + lines.push(' val id: String,'); + lines.push(' val foreground: Int,'); + lines.push(' val ansi: List,'); + lines.push(')'); + lines.push(''); + + for (const theme of terminalThemes) { + lines.push(`private val Terminal${pascal(theme.id)} =`); + lines.push(' TerminalTheme('); + lines.push(` id = ${kotlinString(theme.id)},`); + lines.push(` foreground = ${theme.foreground},`); + lines.push(' ansi ='); + lines.push(' listOf('); + for (const color of theme.ansi) lines.push(` ${color},`); + lines.push(' ),'); + lines.push(' )'); + lines.push(''); + } + + lines.push('/** Every terminal theme, by id. */'); + lines.push('val ALL_TERMINAL_THEMES: Map ='); + lines.push(' listOf('); + for (const theme of terminalThemes) lines.push(` Terminal${pascal(theme.id)},`); + lines.push(' ).associateBy { it.id }'); + lines.push(''); + + for (const { preset, terminalThemeId, colors, shapes } of entries) { + lines.push(`private val ${pascal(preset.id)} =`); + lines.push(' LookPalette('); + lines.push(` id = ${kotlinString(preset.id)},`); + lines.push(` label = ${kotlinString(preset.label)},`); + lines.push(` description = ${kotlinString(preset.description)},`); + lines.push(` dark = ${preset.tone === 'dark'},`); + for (const f of COLOR_FIELDS) lines.push(` ${f} = ${colors[f]},`); + for (const f of SHAPE_FIELDS) lines.push(` ${f} = ${shapes[f]},`); + lines.push(` terminalThemeId = ${kotlinString(terminalThemeId)},`); + lines.push(' )'); + lines.push(''); + } + + lines.push('/** Every preset, in the order the desktop lists them. */'); + lines.push('val ALL_LOOK_PALETTES: List ='); + lines.push(' listOf('); + for (const { preset } of entries) lines.push(` ${pascal(preset.id)},`); + lines.push(' )'); + lines.push(''); + + return lines.join('\n'); +} + +const generated = render(); +const relativeOut = OUT.slice(root.length + 1); +const regenerate = 'Run: npm run generate:android-looks'; + +if (process.argv.includes('--check')) { + let current = null; + try { + current = readFileSync(OUT, 'utf8'); + } catch { + console.error(`${relativeOut} is missing. ${regenerate}`); + process.exit(1); + } + if (current !== generated) { + console.error( + `${relativeOut} is out of date with src/lib/look.ts and src/styles.css.\n${regenerate}`, + ); + process.exit(1); + } + console.log(`${relativeOut} is up to date (${entries.length} presets).`); +} else { + writeFileSync(OUT, generated); + console.log(`Wrote ${relativeOut} (${entries.length} presets).`); +} diff --git a/src/remote/AgentDetail.client.test.tsx b/src/remote/AgentDetail.client.test.tsx index 8d62e036..8ffb5ee8 100644 --- a/src/remote/AgentDetail.client.test.tsx +++ b/src/remote/AgentDetail.client.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'solid-js/web'; import { AgentDetail } from './AgentDetail'; +import { fetchMergeReadiness, fetchNotes, fetchTaskDiff, mergeTask, closeTask } from './api'; import { sendInput } from './ws'; const terminalMocks = vi.hoisted(() => ({ @@ -42,6 +43,15 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom() {} }, })); +const wsMocks = vi.hoisted(() => ({ canControl: true })); + +const READY_MERGE = { + readiness: { overall: 'ready' as const, checks: [] }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', +}; + vi.mock('./ws', () => ({ agents: () => [ { @@ -53,7 +63,7 @@ vi.mock('./ws', () => ({ }, ], status: () => 'connected', - canControl: () => true, + canControl: () => wsMocks.canControl, reconnect: vi.fn(), subscribeAgent: vi.fn(), unsubscribeAgent: vi.fn(), @@ -67,6 +77,15 @@ vi.mock('./ws', () => ({ vi.mock('./api', () => ({ fetchNotes: vi.fn().mockResolvedValue('Notes from desktop'), saveNotes: vi.fn(), + fetchTaskDiff: vi.fn().mockResolvedValue({ diff: '', truncated: false }), + fetchMergeReadiness: vi.fn().mockResolvedValue({ + readiness: { overall: 'ready', checks: [] }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', + }), + mergeTask: vi.fn().mockResolvedValue(undefined), + closeTask: vi.fn().mockResolvedValue(undefined), ApiError: class extends Error {}, })); @@ -74,6 +93,14 @@ let host: HTMLDivElement; let dispose: () => void; beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks drops queued one-shot values too, but re-establish the + // defaults so each test starts from a known API state. + vi.mocked(fetchNotes).mockResolvedValue('Notes from desktop'); + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + vi.mocked(fetchMergeReadiness).mockResolvedValue(READY_MERGE); + vi.mocked(mergeTask).mockResolvedValue(undefined); + vi.mocked(closeTask).mockResolvedValue({ warnings: [] }); + wsMocks.canControl = true; terminalMocks.options.fontSize = 14; localStorage.clear(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); @@ -85,13 +112,13 @@ afterEach(() => { host.remove(); vi.restoreAllMocks(); }); -function mount() { +function mount(onBack: () => void = () => {}) { dispose = render( () => ( {}} + onBack={onBack} onNeedsPairing={() => {}} onNextTask={() => {}} /> @@ -104,6 +131,29 @@ function composer() { if (!field) throw new Error('Missing composer'); return field; } +function tab(label: string) { + const button = [...host.querySelectorAll('nav button')].find( + (b) => b.textContent === label, + ); + if (!button) throw new Error(`Missing ${label} tab`); + return button; +} +async function openTab(label: string) { + tab(label).click(); + await Promise.resolve(); + await Promise.resolve(); +} +function dialogButton(label: string) { + const button = [ + ...host.querySelectorAll('.mobile-dialog-actions button'), + ].find((b) => b.textContent === label); + if (!button) throw new Error(`Missing dialog button: ${label}`); + return button; +} +async function openMergeDialog() { + host.querySelector('.mobile-diff-actions')?.click(); + await vi.waitFor(() => expect(host.querySelector('.mobile-dialog')).not.toBeNull()); +} function type(text: string) { composer().value = text; composer().dispatchEvent(new Event('input', { bubbles: true })); @@ -301,6 +351,7 @@ describe('phone terminal viewport', () => { expect([...host.querySelectorAll('nav button')].map((button) => button.textContent)).toEqual([ 'Terminal', 'Notes', + 'Diff', ]); expect(host.querySelector('.mobile-tabs [aria-label="Smaller terminal text"]')).not.toBeNull(); expect(host.querySelector('.mobile-tabs [aria-label="Larger terminal text"]')).not.toBeNull(); @@ -317,6 +368,212 @@ describe('phone terminal viewport', () => { expect(host.querySelector('[aria-label="Smaller terminal text"]')).toBeNull(); }); + it('renders the task diff in the Diff tab and hides the terminal composer', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ + diff: 'diff --git a/src/a.ts b/src/a.ts\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n', + truncated: false, + }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => + expect(host.querySelector('.mobile-diff-path')?.textContent).toBe('src/a.ts'), + ); + // Files start collapsed so a long change list stays scannable on a phone. + expect(host.querySelector('.mobile-diff-line-add')).toBeNull(); + host.querySelector('.mobile-diff-file-head')?.click(); + await vi.waitFor(() => expect(host.querySelector('.mobile-diff-line-add')).not.toBeNull()); + expect(host.querySelector('.mobile-diff-line-add')?.textContent).toBe('new'); + expect(host.querySelector('.mobile-diff-line-remove')?.textContent).toBe('old'); + // The prompt box types into the terminal, so it must not appear on Diff. + expect(host.querySelector('[aria-label="Message agent"]')).toBeNull(); + // Nor may the Notes save footer leak onto a read-only tab. + expect(host.querySelector('.mobile-notes-footer')).toBeNull(); + }); + + it('still offers the notes footer on Notes, so the views stay independent', async () => { + mount(); + await openTab('Notes'); + expect(host.querySelector('.mobile-notes-footer')).not.toBeNull(); + expect(host.querySelector('[aria-label="Message agent"]')).toBeNull(); + }); + + it('says so when the desktop truncated a diff too large for the phone', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ + diff: 'diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-a\n+b\n', + truncated: true, + }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('too large for the phone')); + }); + + it('reports no changes when the task has nothing to compare', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('No changes yet.')); + }); + + it('explains a task with no branch instead of claiming there are no changes', async () => { + // Without the flag an empty diff reads as "nothing to review", which is wrong + // for a task that works directly in the project folder. + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false, unsupported: true }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('no branch to compare')); + expect(host.textContent).not.toContain('No changes yet.'); + }); + + it('surfaces a diff load failure', async () => { + vi.mocked(fetchTaskDiff).mockRejectedValue(new Error('git unavailable')); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('git unavailable')); + }); + + it('refetches the diff when re-entering the tab so new work shows up', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + mount(); + await openTab('Diff'); + await openTab('Terminal'); + await openTab('Diff'); + await vi.waitFor(() => expect(fetchTaskDiff).toHaveBeenCalledTimes(2)); + }); + + it('shows the desktop readiness checks read-only in the merge dialog', async () => { + vi.mocked(fetchMergeReadiness).mockResolvedValue({ + readiness: { + overall: 'attention', + checks: [ + { label: 'Merge safety', status: 'warning', detail: 'main is 2 commits ahead.' }, + { label: 'Verification', status: 'warning', detail: 'No verification was reported.' }, + ], + }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', + }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Needs attention')); + expect(host.textContent).toContain('main is 2 commits ahead.'); + expect(host.textContent).toContain('No verification was reported.'); + // A warning is advisory: the merge action stays available. + expect(dialogButton('Merge').disabled).toBe(false); + }); + + it('disables merging when readiness reports a blocker', async () => { + vi.mocked(fetchMergeReadiness).mockResolvedValue({ + readiness: { + overall: 'blocked', + checks: [ + { label: 'Merge safety', status: 'blocked', detail: 'Worktree has a detached HEAD.' }, + ], + }, + canMerge: false, + baseBranch: 'main', + branchName: 'task/thing', + }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Not ready to merge')); + expect(dialogButton('Merge').disabled).toBe(true); + }); + + it('merges with the chosen options and returns to the list', async () => { + const back = vi.fn(); + mount(back); + await openTab('Diff'); + await openMergeDialog(); + // First tap opens the confirm step; the option checkboxes live there. + dialogButton('Merge').click(); + await vi.waitFor(() => + expect(host.querySelectorAll('.mobile-dialog-option input').length).toBe(2), + ); + for (const box of host.querySelectorAll('.mobile-dialog-option input')) { + box.checked = true; + box.dispatchEvent(new Event('change', { bubbles: true })); + } + dialogButton('Merge').click(); + await vi.waitFor(() => + expect(mergeTask).toHaveBeenCalledWith('t1', { squash: true, cleanup: true }), + ); + // A merged task may be gone, so the list is where the user should land. + await vi.waitFor(() => expect(back).toHaveBeenCalled()); + }); + + it('surfaces a merge failure and stays in the dialog', async () => { + vi.mocked(mergeTask).mockRejectedValueOnce(new Error('Only worktree tasks can be merged')); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Merge').click(); + dialogButton('Merge').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Only worktree tasks can be merged')); + expect(host.querySelector('.mobile-dialog')).not.toBeNull(); + }); + + it('closes a task after an explicit confirmation', async () => { + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + // The first Close only opens the confirmation; nothing is destroyed yet. + expect(closeTask).not.toHaveBeenCalled(); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(closeTask).toHaveBeenCalledWith('t1', false)); + }); + + it('keeps the dialog open and explains when a close would lose work', async () => { + vi.mocked(closeTask).mockResolvedValue({ warnings: ['2 child tasks still running'] }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + dialogButton('Close task').click(); + // The desktop refused; the reason is shown instead of navigating away. + await vi.waitFor(() => expect(host.textContent).toContain('2 child tasks still running')); + expect(host.querySelector('.mobile-dialog')).not.toBeNull(); + }); + + it('retries a refused close with force only once the user asks', async () => { + vi.mocked(closeTask) + .mockResolvedValueOnce({ warnings: ['uncommitted changes'] }) + .mockResolvedValueOnce({ warnings: [] }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(host.textContent).toContain('uncommitted changes')); + // The force checkbox only appears after a refusal, and stays opt-in. + const forceBox = host.querySelector('.mobile-dialog-option input'); + if (!forceBox) throw new Error('Missing force checkbox'); + expect(closeTask).toHaveBeenLastCalledWith('t1', false); + forceBox.checked = true; + forceBox.dispatchEvent(new Event('change', { bubbles: true })); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(closeTask).toHaveBeenLastCalledWith('t1', true)); + }); + + it('hides merge and close for a view-only device', async () => { + wsMocks.canControl = false; + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Pair this phone')); + const labels = [...host.querySelectorAll('.mobile-dialog-actions button')].map( + (b) => b.textContent, + ); + expect(labels).not.toContain('Merge'); + expect(labels).not.toContain('Close'); + }); + it('pans the desktop grid before scrolling history and shields gestures from xterm', () => { localStorage.setItem('parallel-mobile:output-view', 'terminal'); mount(); diff --git a/src/remote/AgentDetail.tsx b/src/remote/AgentDetail.tsx index 506e4924..3c18412e 100644 --- a/src/remote/AgentDetail.tsx +++ b/src/remote/AgentDetail.tsx @@ -3,6 +3,8 @@ import { Terminal } from '@xterm/xterm'; import { TERMINAL_SCROLL_OPTIONS, base64ToUint8Array } from '../lib/terminalConstants'; import { createTerminalHttpLinkHandler } from '../lib/terminalLinks'; import { fetchNotes, saveNotes, ApiError } from './api'; +import { TaskDiffView } from './TaskDiffView'; +import { TaskActionsDialog } from './TaskActionsDialog'; import { clearPairedToken } from './auth'; import { readLocal, writeLocal } from './storage'; import { messageForTerminal } from './terminalText'; @@ -53,7 +55,10 @@ export function AgentDetail(props: AgentDetailProps) { const [sending, setSending] = createSignal(false); const [sendError, setSendError] = createSignal(''); const [sent, setSent] = createSignal(false); - const [view, setView] = createSignal<'terminal' | 'notes'>('terminal'); + const [view, setView] = createSignal<'terminal' | 'notes' | 'diff'>('terminal'); + // Bumped to force the diff tab to refetch; the resource keys on it. + const [diffToken, setDiffToken] = createSignal(0); + const [showActions, setShowActions] = createSignal(false); const [multilinePaste, setMultilinePaste] = createSignal(false); const [terminalBottom, setTerminalBottom] = createSignal(true); const [zoom, setZoom] = createSignal(1); @@ -407,8 +412,12 @@ export function AgentDetail(props: AgentDetailProps) { setSent(false); } - function selectView(next: 'terminal' | 'notes') { + function selectView(next: 'terminal' | 'notes' | 'diff') { + const wasDiff = view() === 'diff'; setView(next); + // Re-entering the diff tab refetches, so returning to it always shows the + // agent's newest work rather than a snapshot from the first visit. + if (next === 'diff' && wasDiff) setDiffToken((n) => n + 1); requestAnimationFrame(() => { if (!disposed) { fitTerminal(); @@ -431,6 +440,7 @@ export function AgentDetail(props: AgentDetailProps) { each={[ { id: 'terminal' as const, label: 'Terminal' }, { id: 'notes' as const, label: 'Notes' }, + { id: 'diff' as const, label: 'Diff' }, ]} > {(tab) => ( @@ -503,6 +513,19 @@ export function AgentDetail(props: AgentDetailProps) { /> + +
+ + + + +
+
@@ -524,98 +547,103 @@ export function AgentDetail(props: AgentDetailProps) {
+ {/* The composer types into the agent's terminal, so it belongs to the + terminal view only. The notes footer belongs to Notes alone, and the + read-only Diff tab gets neither. */} - -

This terminal sends line breaks as spaces.

-
- - - -
- - + +