diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17f75d0..f6c8f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,117 +1,74 @@ name: ci on: - pull_request: - push: - branches: - - master + pull_request: + push: + branches: [master] permissions: - contents: read - pull-requests: write + contents: read jobs: - android: - name: Android - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup JDK 21 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - cache: gradle - - - name: Lint & Test - run: ./gradlew :app:check - - - name: Generate Kotlin coverage XML - run: ./gradlew :app:koverXmlReport - - - name: Comment coverage on PR - if: github.event_name == 'pull_request' - uses: madrapps/jacoco-report@v1.7.1 - with: - paths: ${{ github.workspace }}/app/build/reports/kover/report.xml - token: ${{ secrets.GITHUB_TOKEN }} - title: Kotlin Coverage - update-comment: true - - bridge: - name: Bridge - runs-on: ubuntu-latest - defaults: - run: - working-directory: bridge - steps: - - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: bridge/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint, Typecheck & Test - run: pnpm check - - - name: Generate bridge coverage - run: pnpm run test:coverage - - - name: Build bridge coverage PR comment - if: github.event_name == 'pull_request' - run: | - node <<'NODE' - const fs = require('node:fs'); - - const summaryPath = 'coverage/coverage-summary.json'; - if (!fs.existsSync(summaryPath)) { - throw new Error(`Coverage summary not found at ${summaryPath}`); - } - - const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); - const total = summary.total ?? {}; - - const metricRow = (key, label) => { - const metric = total[key] ?? { covered: 0, total: 0, pct: 0 }; - const covered = Number(metric.covered ?? 0); - const totalCount = Number(metric.total ?? 0); - const pct = Number(metric.pct ?? 0).toFixed(2); - return `| ${label} | ${covered} / ${totalCount} | ${pct}% |`; - }; - - const markdown = [ - '## Bridge Coverage (Vitest)', - '', - '| Metric | Covered / Total | Coverage |', - '|---|---:|---:|', - metricRow('lines', 'Lines'), - metricRow('functions', 'Functions'), - metricRow('branches', 'Branches'), - metricRow('statements', 'Statements'), - '', - '_Source: bridge/coverage/coverage-summary.json_', - '', - ].join('\n'); - - fs.mkdirSync('coverage', { recursive: true }); - fs.writeFileSync('coverage/pr-comment.md', markdown); - NODE - - - name: Comment bridge coverage on PR - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: bridge-coverage - path: bridge/coverage/pr-comment.md + android: + name: Android static, unit, lint, and assembly + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: gradle + - uses: android-actions/setup-android@v3 + - name: Install Android SDK 36 + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" + - name: Verify Android + run: ./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: pi-mobile-debug + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + + android-connected: + name: Android connected tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: gradle + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_7 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: ./gradlew :app:connectedDebugAndroidTest + + bridge: + name: Bridge checks and production audit + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: bridge + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: bridge/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - run: pnpm audit --prod --audit-level high diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..39bff98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Pi Mobile contributor and agent guide + +## Architecture invariants + +- Preserve Android → authenticated WebSocket bridge → one isolated `pi --mode rpc` process per cwd. +- Do not migrate the bridge to the Pi SDK or remove cwd/session control locks. +- Pi 0.80.6 is the minimum tested runtime. +- Use documented RPC commands only. Active reads use `get_entries` and `get_tree`; the internal extension exists only for tree navigation because Pi 0.80.6 has no navigation RPC command. +- Unknown session entries trigger one explicit full rebuild. Never guess private session-file behavior. + +## Modules + +- `app`: Compose UI, screen state, host/token storage, and session controller. +- `core-rpc`: typed Pi commands/events and sanitized compatibility fixtures. +- `core-net`: WebSocket transport, authenticated bridge control, request correlation, reconnect, and entry cursors. +- `core-sessions`: cached session-index models and repository. +- `bridge`: authentication, WebSocket envelopes, locks, process lifecycle, inactive-session indexing, and internal extensions. +- `benchmark`: device-owned macrobenchmark/profile scaffolding. + +Keep regression tests beside the owning module. Protocol fixtures belong in `core-rpc/src/test/resources/rpc`; they must be sanitized. + +## Required commands + +Use JDK 21, Node 22+, pnpm 10, Android SDK 36, and Pi 0.80.6+. + +```bash +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) +``` + +Compile device tests without launching a device: + +```bash +./gradlew :app:compileDebugAndroidTestKotlin +``` + +Do not run connected/device acceptance unless the operator explicitly owns and requests it. + +## Secrets + +- Never print, render, log, commit, or copy tokens, authorization headers, `.env` contents, credentials, or private sessions. +- Tokens use Android Keystore AES-GCM and must not enter ordinary preferences or backup. +- Do not add signing credentials. Release assembly uses repository-safe unsigned/default configuration. + +## Plan protocol + +1. Read `plans/README.md`, the complete active plan, referenced docs, and current Pi docs. +2. Run the plan drift check and mark the row `IN PROGRESS` before editing. +3. Add characterization/regression tests with behavior changes. +4. Run focused checks, full plan gates, and `git diff --check`. +5. Use small Conventional Commits commits. Never push, merge, or rewrite history. +6. Mark a plan done only when non-device gates pass. Device-only validation is recorded as `PENDING — operator-owned` with executable steps and evidence fields. diff --git a/README.md b/README.md index 74d8d03..2566b26 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The bridge is a small Node.js service that translates WebSocket to pi's stdin/st - [Custom extensions](docs/extensions.md) - [Bridge protocol reference](docs/bridge-protocol.md) - [Testing guide](docs/testing.md) +- [Onboarding and recovery](docs/onboarding.md) > Note: `docs/ai/` contains planning/progress artifacts used during development. User-facing and maintenance docs live in the top-level `docs/` files above. @@ -67,16 +68,19 @@ The bridge is a small Node.js service that translates WebSocket to pi's stdin/st Install pi if you haven't: ```bash -npm install -g @mariozechner/pi-coding-agent +npm install -g @earendil-works/pi-coding-agent@^0.80.6 +pi --version # minimum and tested version: 0.80.6 ``` Clone and start the bridge: ```bash -git clone https://github.com/yourusername/pi-mobile.git +git clone https://github.com/ayagmar/pi-mobile.git cd pi-mobile/bridge pnpm install # create .env and set BRIDGE_AUTH_TOKEN (see Configuration section below) pnpm start +# In another shell, when BRIDGE_ENABLE_HEALTH_ENDPOINT=true: +curl --fail http://127.0.0.1:8787/health ``` The bridge binds to `127.0.0.1:8787` by default. Set `BRIDGE_HOST` to your laptop Tailscale IP to allow phone access (avoid `0.0.0.0` unless you enforce firewall restrictions). It spawns pi processes on demand per working directory. @@ -91,15 +95,24 @@ adb install app/build/outputs/apk/debug/app-debug.apk ### 3. Connect -1. Add a host in the app: +1. With the bridge configured, print a pairing code: + + ```bash + cd bridge + pnpm pair + ``` + +2. In Pi Mobile, open **Hosts**, tap **Scan QR**, scan the terminal code, review the populated connection, and save it. The QR uses the existing `BRIDGE_AUTH_TOKEN`; keep it private. If automatic Tailscale hostname discovery is unavailable, run `pnpm pair -- --host `. + +3. Manual entry remains available: - Host: your laptop's Tailscale MagicDNS hostname (`..ts.net`) - Port: `8787` (or whatever the bridge uses) - Use TLS: off for local/Tailscale bridge unless you've put TLS in front - - Token: set this in `bridge/.env` as `BRIDGE_AUTH_TOKEN` + - Token: set this in `bridge/.env` as `BRIDGE_AUTH_TOKEN`; stored tokens are never displayed -2. The app will fetch your sessions from `~/.pi/agent/sessions/` (or `BRIDGE_SESSION_DIR` if overridden) +4. Use the connection test to distinguish network, authentication, and Pi readiness failures. The app fetches sessions from `~/.pi/agent/sessions/` (or `BRIDGE_SESSION_DIR` if overridden). -3. Tap a session to resume it +5. Tap a session to resume it. ## How It Works @@ -191,7 +204,7 @@ benchmark/ - Macrobenchmark / baseline profile scaffolding ### Running Tests -Use JDK 21 for Android and Gradle work in this repo. +Use JDK 21, Android SDK 36, Node 22+, and pnpm 10 for this repo. See [the dependency matrix](docs/dependency-matrix.md). ```bash # Android tests @@ -203,8 +216,8 @@ cd bridge && pnpm test # Bridge full checks (lint + typecheck + tests) cd bridge && pnpm run check -# All Android quality checks -./gradlew ktlintCheck detekt test +# Complete non-device Android gate +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease ``` ### Logs to Watch @@ -238,6 +251,9 @@ BRIDGE_RECONNECT_GRACE_MS=30000 # Keep control locks after disconnect (ms) BRIDGE_SESSION_DIR=/absolute/path/to/.pi/agent/sessions # Override the session dir used for indexing and spawned pi runtimes BRIDGE_LOG_LEVEL=info # fatal,error,warn,info,debug,trace,silent BRIDGE_ENABLE_HEALTH_ENDPOINT=true # set false to disable /health endpoint +BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES=16777216 # maximum WebSocket message size (16 MiB) +BRIDGE_IMPORT_MAX_BYTES=10485760 # maximum UTF-8 JSONL import size (10 MiB) +BRIDGE_PI_COMMAND=pi # Pi executable path/name; probed with --version at startup ``` ### App Build Variants diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b28ad8..10f571f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,21 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlinx.kover") } android { namespace = "com.ayagmar.pimobile" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "com.ayagmar.pimobile" minSdk = 26 - targetSdk = 34 + targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -34,18 +37,14 @@ android { targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" + kotlin { + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) } buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -57,6 +56,7 @@ android { checkReleaseBuilds = true warningsAsErrors = true disable += "GradleDependency" + disable += "OldTargetApi" baseline = file("lint-baseline.xml") } } @@ -70,14 +70,13 @@ dependencies { implementation(project(":core-net")) implementation(project(":core-sessions")) - implementation(platform("androidx.compose:compose-bom:2024.06.00")) + implementation(platform("androidx.compose:compose-bom:2026.06.00")) implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.activity:activity-compose:1.9.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") - implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.navigation:navigation-compose:2.9.8") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("io.coil-kt:coil-compose:2.6.0") @@ -85,6 +84,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") implementation("com.github.jeziellago:compose-markdown:0.7.0") implementation("io.github.java-diff-utils:java-diff-utils:4.15") implementation("io.noties:prism4j:2.0.0") { @@ -97,7 +97,7 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00")) + androidTestImplementation(platform("androidx.compose:compose-bom:2026.06.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test:core-ktx:1.5.0") diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index d3705fa..f71ccdd 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,180 +1,2 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6697edb..e7ef611 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,9 @@ + + diff --git a/app/src/main/java/com/ayagmar/pimobile/MainActivity.kt b/app/src/main/java/com/ayagmar/pimobile/MainActivity.kt index 754bd7c..73d338e 100644 --- a/app/src/main/java/com/ayagmar/pimobile/MainActivity.kt +++ b/app/src/main/java/com/ayagmar/pimobile/MainActivity.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.lifecycleScope import com.ayagmar.pimobile.di.AppGraph import com.ayagmar.pimobile.perf.PerformanceMetrics import com.ayagmar.pimobile.perf.PerformanceMetrics.recordAppStart -import com.ayagmar.pimobile.ui.piMobileApp +import com.ayagmar.pimobile.ui.PiMobileApp import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -21,7 +21,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { - piMobileApp(appGraph = appGraph) + PiMobileApp(appGraph = appGraph) } } diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatHistoryParsing.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatHistoryParsing.kt new file mode 100644 index 0000000..75a90bd --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatHistoryParsing.kt @@ -0,0 +1,189 @@ +package com.ayagmar.pimobile.chat + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +internal fun historyWindowSignature(messages: List): String { + if (messages.isEmpty()) return "empty" + + val marker = + messages + .joinToString(separator = "|") { message -> + val role = message.stringField("role").orEmpty() + val entryId = message.stringField("entryId").orEmpty() + "$role:$entryId:${message.toString().hashCode()}" + } + + return "${messages.size}:$marker" +} + +internal fun extractHistoryMessageWindow(data: JsonObject?): HistoryMessageWindow { + val rawMessages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + val startIndex = (rawMessages.size - HISTORY_WINDOW_MAX_ITEMS).coerceAtLeast(0) + + val messages = + rawMessages + .drop(startIndex) + .mapNotNull { messageElement -> + runCatching { messageElement.jsonObject }.getOrNull() + } + + return HistoryMessageWindow( + messages = messages, + absoluteOffset = startIndex, + ) +} + +internal fun parseHistoryItems( + messages: List, + absoluteIndexOffset: Int, + startIndex: Int = 0, + endExclusive: Int = messages.size, +): List { + if (messages.isEmpty()) { + return emptyList() + } + + val boundedStart = startIndex.coerceIn(0, messages.size) + val boundedEnd = endExclusive.coerceIn(boundedStart, messages.size) + + return (boundedStart until boundedEnd).mapNotNull { index -> + val message = messages[index] + val absoluteIndex = absoluteIndexOffset + index + + when (message.stringField("role")) { + "user" -> { + val content = message["content"] + val text = extractUserText(content) + val imageCount = extractUserImageCount(content) + ChatTimelineItem.User( + id = "history-user-$absoluteIndex", + text = text, + imageCount = imageCount, + ) + } + + "assistant" -> { + val text = extractAssistantText(message["content"]) + val thinking = extractAssistantThinking(message["content"]) + ChatTimelineItem.Assistant( + id = "history-assistant-$absoluteIndex", + text = text, + thinking = thinking, + isThinkingComplete = thinking != null, + isStreaming = false, + ) + } + + "toolResult" -> { + val output = extractToolOutput(message) + ChatTimelineItem.Tool( + id = "history-tool-$absoluteIndex", + toolName = message.stringField("toolName") ?: "tool", + output = output, + isCollapsed = output.length > 400, + isStreaming = false, + isError = message.booleanField("isError") ?: false, + arguments = emptyMap(), + editDiff = null, + ) + } + + else -> null + } + } +} + +internal fun extractUserText(content: JsonElement?): String { + return when (content) { + null -> "" + is JsonObject -> content.stringField("text").orEmpty() + else -> { + runCatching { + when (content) { + is kotlinx.serialization.json.JsonPrimitive -> content.contentOrNull.orEmpty() + else -> { + content.jsonArray + .mapNotNull { block -> + block.jsonObject.takeIf { it.stringField("type") == "text" }?.stringField("text") + }.joinToString("\n") + } + } + }.getOrDefault("") + } + } +} + +internal fun extractUserImageCount(content: JsonElement?): Int { + return runCatching { + when (content) { + null -> 0 + is kotlinx.serialization.json.JsonPrimitive -> 0 + is JsonObject -> { + val type = content.stringField("type")?.lowercase().orEmpty() + if ("image" in type) 1 else 0 + } + else -> { + content.jsonArray.count { block -> + val blockObject = runCatching { block.jsonObject }.getOrNull() ?: return@count false + val type = blockObject.stringField("type")?.lowercase().orEmpty() + type.contains("image") || + blockObject["image"] != null || + blockObject["imageUrl"] != null || + blockObject["image_url"] != null + } + } + } + }.getOrDefault(0) +} + +internal fun extractAssistantText(content: JsonElement?): String { + val contentArray = runCatching { content?.jsonArray }.getOrNull() ?: return "" + return contentArray + .mapNotNull { block -> + val blockObject = block.jsonObject + if (blockObject.stringField("type") == "text") { + blockObject.stringField("text") + } else { + null + } + }.joinToString("\n") +} + +internal fun extractAssistantThinking(content: JsonElement?): String? { + val contentArray = runCatching { content?.jsonArray }.getOrNull() ?: return null + val thinkingBlocks = + contentArray + .mapNotNull { block -> + val blockObject = block.jsonObject + if (blockObject.stringField("type") == "thinking") { + blockObject.stringField("thinking") + } else { + null + } + } + return thinkingBlocks.takeIf { it.isNotEmpty() }?.joinToString("\n") +} + +internal fun extractToolOutput(source: JsonObject?): String { + return source?.let { jsonSource -> + val fromContent = + runCatching { + jsonSource["content"]?.jsonArray + ?.mapNotNull { block -> + val blockObject = block.jsonObject + if (blockObject.stringField("type") == "text") { + blockObject.stringField("text") + } else { + null + } + }?.joinToString("\n") + }.getOrNull() + + fromContent?.takeIf { it.isNotBlank() } ?: jsonSource.stringField("output").orEmpty() + }.orEmpty() +} diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatModelsAndParsing.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatModelsAndParsing.kt new file mode 100644 index 0000000..e029438 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatModelsAndParsing.kt @@ -0,0 +1,231 @@ +package com.ayagmar.pimobile.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.ayagmar.pimobile.corenet.ConnectionState +import com.ayagmar.pimobile.corerpc.AvailableModel +import com.ayagmar.pimobile.corerpc.SessionStats +import com.ayagmar.pimobile.sessions.ModelInfo +import com.ayagmar.pimobile.sessions.SessionController +import com.ayagmar.pimobile.sessions.SessionTreeSnapshot +import com.ayagmar.pimobile.sessions.SlashCommandInfo +import kotlinx.serialization.json.JsonObject + +data class ChatUiState( + val isLoading: Boolean = false, + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val isStreaming: Boolean = false, + val isRetrying: Boolean = false, + val timeline: List = emptyList(), + val hasOlderMessages: Boolean = false, + val hiddenHistoryCount: Int = 0, + val inputText: String = "", + val errorMessage: String? = null, + val currentModel: ModelInfo? = null, + val thinkingLevel: String? = null, + val sessionName: String? = null, + val pendingMessageCount: Int = 0, + val activeExtensionRequest: ExtensionUiRequest? = null, + val notifications: List = emptyList(), + val extensionWidgets: Map = emptyMap(), + val extensionStatuses: Map = emptyMap(), + val extensionTitle: String? = null, + val isCommandPaletteVisible: Boolean = false, + val isCommandPaletteAutoOpened: Boolean = false, + val commands: List = emptyList(), + val commandsQuery: String = "", + val isLoadingCommands: Boolean = false, + val steeringMode: String = ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME, + val followUpMode: String = ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME, + val pendingQueueItems: List = emptyList(), + val pendingClipboardText: String? = null, + val pendingImportRequestToken: String? = null, + val isSyncingSession: Boolean = false, + val sessionCoherencyWarning: String? = null, + // Bash dialog state + val isBashDialogVisible: Boolean = false, + val bashCommand: String = "", + val bashOutput: String = "", + val bashExitCode: Int? = null, + val isBashExecuting: Boolean = false, + val bashWasTruncated: Boolean = false, + val bashFullLogPath: String? = null, + val bashHistory: List = emptyList(), + // Tool argument expansion state (per tool ID) + val expandedToolArguments: Set = emptySet(), + // Session stats state + val isStatsSheetVisible: Boolean = false, + val sessionStats: SessionStats? = null, + val isLoadingStats: Boolean = false, + // Model picker state + val isModelPickerVisible: Boolean = false, + val availableModels: List = emptyList(), + val modelsQuery: String = "", + val isLoadingModels: Boolean = false, + // Session tree state + val isTreeSheetVisible: Boolean = false, + val treeFilter: String = ChatViewModel.TREE_FILTER_DEFAULT, + val sessionTree: SessionTreeSnapshot? = null, + val isLoadingTree: Boolean = false, + val treeErrorMessage: String? = null, + // Image attachments + val pendingImages: List = emptyList(), +) + +data class PendingImage( + val uri: String, + val mimeType: String, + val sizeBytes: Long, + val displayName: String?, +) + +data class PendingQueueItem( + val id: String, + val type: PendingQueueType, + val message: String, + val mode: String, +) + +enum class PendingQueueType { + STEER, + FOLLOW_UP, +} + +data class ExtensionNotification( + val message: String, + val type: String, +) + +data class ExtensionWidget( + val lines: List, + val placement: String, +) + +sealed interface ExtensionUiRequest { + val requestId: String + + data class Select( + override val requestId: String, + val title: String, + val options: List, + ) : ExtensionUiRequest + + data class Confirm( + override val requestId: String, + val title: String, + val message: String, + ) : ExtensionUiRequest + + data class Input( + override val requestId: String, + val title: String, + val placeholder: String?, + ) : ExtensionUiRequest + + data class Editor( + override val requestId: String, + val title: String, + val prefill: String, + ) : ExtensionUiRequest +} + +sealed interface ChatTimelineItem { + val id: String + + data class User( + override val id: String, + val text: String, + val imageCount: Int = 0, + val imageUris: List = emptyList(), + ) : ChatTimelineItem + + data class Assistant( + override val id: String, + val text: String, + val thinking: String? = null, + val isThinkingExpanded: Boolean = false, + val isThinkingComplete: Boolean = false, + val isStreaming: Boolean, + ) : ChatTimelineItem + + data class Tool( + override val id: String, + val toolName: String, + val output: String, + val isCollapsed: Boolean, + val isStreaming: Boolean, + val isError: Boolean, + val arguments: Map = emptyMap(), + val editDiff: EditDiffInfo? = null, + val isDiffExpanded: Boolean = false, + ) : ChatTimelineItem +} + +/** + * Information about a file edit for diff display. + */ +data class EditDiffInfo( + val path: String, + val oldString: String, + val newString: String, +) + +class ChatViewModelFactory( + private val sessionController: SessionController, + private val imageEncoder: ImageEncoder? = null, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + check(modelClass == ChatViewModel::class.java) { + "Unsupported ViewModel class: ${modelClass.name}" + } + + @Suppress("UNCHECKED_CAST") + return ChatViewModel( + sessionController = sessionController, + imageEncoder = imageEncoder, + ) as T + } +} + +internal data class InitialLoadMetadata( + val modelInfo: ModelInfo?, + val thinkingLevel: String?, + val isStreaming: Boolean, + val steeringMode: String, + val followUpMode: String, + val sessionPath: String?, + val sessionName: String?, + val pendingMessageCount: Int, +) + +internal data class DraftClearState( + val inputWasCleared: Boolean, + val imagesWereCleared: Boolean, +) + +internal data class ThinkingDiagnosticsCounters( + val startEvents: Int = 0, + val deltaEvents: Int = 0, + val deltaChars: Int = 0, + val endEvents: Int = 0, + val endPayloadEvents: Int = 0, + val endPayloadChars: Int = 0, + val renderedThinkingUpdates: Int = 0, + val renderedThinkingCompleteEvents: Int = 0, +) + +internal data class StreamingDeltaDiagnosticsCounters( + val messageUpdateEvents: Int = 0, + val assistantDeltaEvents: Int = 0, + val textDeltaEvents: Int = 0, + val thinkingDeltaEvents: Int = 0, + val assistantNonDeltaEvents: Int = 0, + val coalescedDeltaEvents: Int = 0, + val emittedImmediateDeltaEvents: Int = 0, + val emittedFlushedDeltaEvents: Int = 0, +) + +internal data class HistoryMessageWindow( + val messages: List, + val absoluteOffset: Int, +) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatRpcParsing.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatRpcParsing.kt new file mode 100644 index 0000000..686eb93 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatRpcParsing.kt @@ -0,0 +1,76 @@ +package com.ayagmar.pimobile.chat + +import com.ayagmar.pimobile.sessions.ModelInfo +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +internal fun JsonObject.stringField(fieldName: String): String? { + return this[fieldName]?.jsonPrimitive?.contentOrNull +} + +internal fun JsonObject.booleanField(fieldName: String): Boolean? { + return this[fieldName]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() +} + +internal fun JsonObject.intField(fieldName: String): Int? { + return this[fieldName]?.jsonPrimitive?.intOrNull +} + +internal fun JsonObject?.deliveryModeField( + camelCaseKey: String, + snakeCaseKey: String, +): String { + val value = + this?.get(camelCaseKey)?.jsonPrimitive?.contentOrNull + ?: this?.get(snakeCaseKey)?.jsonPrimitive?.contentOrNull + + return value?.takeIf { + it == ChatViewModel.DELIVERY_MODE_ALL || it == ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME + } ?: ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME +} + +internal fun parseModelInfo(data: JsonObject?): ModelInfo? { + val model = data?.get("model") as? JsonObject ?: return null + return ModelInfo( + id = model.stringField("id") ?: "unknown", + name = model.stringField("name") ?: "Unknown Model", + provider = model.stringField("provider") ?: "unknown", + thinkingLevel = data.stringField("thinkingLevel") ?: "off", + contextWindow = model.intField("contextWindow"), + ) +} + +/** + * Extracts tool arguments from JSON object as a map of string keys to string values. + * Only extracts primitive string arguments for display purposes. + */ +internal fun extractToolArguments(args: JsonObject?): Map { + if (args == null) return emptyMap() + return args + .mapNotNull { (key, value) -> + when { + value is kotlinx.serialization.json.JsonPrimitive && + value.isString -> key to value.content + else -> null + } + }.toMap() +} + +/** + * Extracts edit tool diff information from arguments. + * Returns null if not an edit tool or required fields are missing. + */ +@Suppress("ReturnCount") +internal fun extractEditDiff(args: JsonObject?): EditDiffInfo? { + if (args == null) return null + val path = args.stringField("path") ?: return null + val oldString = args.stringField("oldString") ?: return null + val newString = args.stringField("newString") ?: return null + return EditDiffInfo( + path = path, + oldString = oldString, + newString = newString, + ) +} diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index f1ba5f1..50ee8b5 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -1,10 +1,10 @@ package com.ayagmar.pimobile.chat import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.ayagmar.pimobile.corenet.ConnectionState import com.ayagmar.pimobile.corerpc.AgentEndEvent +import com.ayagmar.pimobile.corerpc.AgentSettledEvent import com.ayagmar.pimobile.corerpc.AssistantTextAssembler import com.ayagmar.pimobile.corerpc.AssistantTextUpdate import com.ayagmar.pimobile.corerpc.AutoCompactionEndEvent @@ -19,7 +19,6 @@ import com.ayagmar.pimobile.corerpc.MessageEndEvent import com.ayagmar.pimobile.corerpc.MessageStartEvent import com.ayagmar.pimobile.corerpc.MessageUpdateEvent import com.ayagmar.pimobile.corerpc.RpcResponse -import com.ayagmar.pimobile.corerpc.SessionStats import com.ayagmar.pimobile.corerpc.ToolExecutionEndEvent import com.ayagmar.pimobile.corerpc.ToolExecutionStartEvent import com.ayagmar.pimobile.corerpc.ToolExecutionUpdateEvent @@ -27,11 +26,9 @@ import com.ayagmar.pimobile.corerpc.TurnEndEvent import com.ayagmar.pimobile.corerpc.TurnStartEvent import com.ayagmar.pimobile.corerpc.UiUpdateThrottler import com.ayagmar.pimobile.perf.PerformanceMetrics -import com.ayagmar.pimobile.sessions.ModelInfo import com.ayagmar.pimobile.sessions.SessionController import com.ayagmar.pimobile.sessions.SessionFreshnessFingerprint import com.ayagmar.pimobile.sessions.SessionFreshnessSnapshot -import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -45,17 +42,11 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import java.util.UUID -private const val HISTORY_WINDOW_MAX_ITEMS = 1_200 +internal const val HISTORY_WINDOW_MAX_ITEMS = 1_200 @Suppress("TooManyFunctions", "LargeClass") class ChatViewModel( @@ -80,6 +71,7 @@ class ChatViewModel( private var streamingDiagnosticsRunActive = false private var streamingDiagnosticsRunStartedAtMs: Long = 0 private var sessionFreshnessMonitorJob: Job? = null + private var isChatActive = false private var latestSessionPath: String? = null private var lastKnownSessionFreshness: SessionFreshnessFingerprint? = null private var localSessionMutationGraceUntilMs: Long = 0 @@ -209,10 +201,11 @@ class ChatViewModel( } private suspend fun encodePendingImages(pendingImages: List): List { + val encoder = imageEncoder + if (encoder == null || pendingImages.isEmpty()) return emptyList() + return withContext(Dispatchers.Default) { - pendingImages.mapNotNull { pending -> - imageEncoder?.encodeToPayload(pending) - } + pendingImages.mapNotNull(encoder::encodeToPayload) } } @@ -713,8 +706,17 @@ class ChatViewModel( } } + fun onChatActiveChanged(active: Boolean) { + isChatActive = active + if (active && sessionController.connectionState.value == ConnectionState.CONNECTED) { + startSessionFreshnessMonitor() + } else if (!active) { + stopSessionFreshnessMonitor() + } + } + private fun startSessionFreshnessMonitor() { - if (sessionFreshnessMonitorJob?.isActive == true || isSessionFreshnessUnsupported) { + if (!isChatActive || sessionFreshnessMonitorJob?.isActive == true || isSessionFreshnessUnsupported) { return } @@ -733,6 +735,9 @@ class ChatViewModel( } private suspend fun refreshSessionFreshness(trigger: FreshnessCheckTrigger) { + if (trigger == FreshnessCheckTrigger.POLL) { + sessionController.recordSafetyPoll() + } if (isSessionFreshnessUnsupported) { return } @@ -939,6 +944,14 @@ class ChatViewModel( } } + viewModelScope.launch { + sessionController.timelineInvalidated.collect { + if (initialLoadJob?.isActive != true) { + loadInitialMessages(reason = TimelineReloadReason.CONNECTION_RECOVERY) + } + } + } + viewModelScope.launch { sessionController.rpcEvents.collect { event -> when (event) { @@ -977,6 +990,7 @@ class ChatViewModel( logThinkingDiagnostics(reason = "agent_end") logStreamingDiagnostics(reason = "agent_end") } + is AgentSettledEvent -> handleAgentSettled() else -> Unit } } @@ -1151,6 +1165,11 @@ class ChatViewModel( } private fun handleTurnEnd() { + // Refresh stats after each low-level turn while keeping the run active until agent_settled. + loadSessionStats() + } + + private fun handleAgentSettled() { clearStreamingTimelineFlags() _uiState.update { it.copy( @@ -1158,9 +1177,6 @@ class ChatViewModel( pendingQueueItems = emptyList(), ) } - - // Refresh stats at turn end so context/cost indicators stay current. - loadSessionStats() } private fun handleExtensionError(event: ExtensionErrorEvent) { @@ -2776,477 +2792,10 @@ class ChatViewModel( private const val STREAMING_DIAGNOSTICS_LOG_TAG = "StreamingDiagnostics" private const val SESSION_COHERENCY_WARNING_MESSAGE = "Potential cross-device session edits detected. Use Sync now before continuing." - private const val SESSION_FRESHNESS_POLL_INTERVAL_MS = 4_000L + + // Safety fallback for edits made outside the bridge; bridge-observed mutations resync separately. + private const val SESSION_FRESHNESS_POLL_INTERVAL_MS = 60_000L private const val SESSION_FRESHNESS_WARNING_COOLDOWN_MS = 20_000L private const val LOCAL_SESSION_MUTATION_GRACE_MS = 90_000L } } - -data class ChatUiState( - val isLoading: Boolean = false, - val connectionState: ConnectionState = ConnectionState.DISCONNECTED, - val isStreaming: Boolean = false, - val isRetrying: Boolean = false, - val timeline: List = emptyList(), - val hasOlderMessages: Boolean = false, - val hiddenHistoryCount: Int = 0, - val inputText: String = "", - val errorMessage: String? = null, - val currentModel: ModelInfo? = null, - val thinkingLevel: String? = null, - val sessionName: String? = null, - val pendingMessageCount: Int = 0, - val activeExtensionRequest: ExtensionUiRequest? = null, - val notifications: List = emptyList(), - val extensionWidgets: Map = emptyMap(), - val extensionStatuses: Map = emptyMap(), - val extensionTitle: String? = null, - val isCommandPaletteVisible: Boolean = false, - val isCommandPaletteAutoOpened: Boolean = false, - val commands: List = emptyList(), - val commandsQuery: String = "", - val isLoadingCommands: Boolean = false, - val steeringMode: String = ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME, - val followUpMode: String = ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME, - val pendingQueueItems: List = emptyList(), - val pendingClipboardText: String? = null, - val pendingImportRequestToken: String? = null, - val isSyncingSession: Boolean = false, - val sessionCoherencyWarning: String? = null, - // Bash dialog state - val isBashDialogVisible: Boolean = false, - val bashCommand: String = "", - val bashOutput: String = "", - val bashExitCode: Int? = null, - val isBashExecuting: Boolean = false, - val bashWasTruncated: Boolean = false, - val bashFullLogPath: String? = null, - val bashHistory: List = emptyList(), - // Tool argument expansion state (per tool ID) - val expandedToolArguments: Set = emptySet(), - // Session stats state - val isStatsSheetVisible: Boolean = false, - val sessionStats: SessionStats? = null, - val isLoadingStats: Boolean = false, - // Model picker state - val isModelPickerVisible: Boolean = false, - val availableModels: List = emptyList(), - val modelsQuery: String = "", - val isLoadingModels: Boolean = false, - // Session tree state - val isTreeSheetVisible: Boolean = false, - val treeFilter: String = ChatViewModel.TREE_FILTER_DEFAULT, - val sessionTree: SessionTreeSnapshot? = null, - val isLoadingTree: Boolean = false, - val treeErrorMessage: String? = null, - // Image attachments - val pendingImages: List = emptyList(), -) - -data class PendingImage( - val uri: String, - val mimeType: String, - val sizeBytes: Long, - val displayName: String?, -) - -data class PendingQueueItem( - val id: String, - val type: PendingQueueType, - val message: String, - val mode: String, -) - -enum class PendingQueueType { - STEER, - FOLLOW_UP, -} - -data class ExtensionNotification( - val message: String, - val type: String, -) - -data class ExtensionWidget( - val lines: List, - val placement: String, -) - -sealed interface ExtensionUiRequest { - val requestId: String - - data class Select( - override val requestId: String, - val title: String, - val options: List, - ) : ExtensionUiRequest - - data class Confirm( - override val requestId: String, - val title: String, - val message: String, - ) : ExtensionUiRequest - - data class Input( - override val requestId: String, - val title: String, - val placeholder: String?, - ) : ExtensionUiRequest - - data class Editor( - override val requestId: String, - val title: String, - val prefill: String, - ) : ExtensionUiRequest -} - -sealed interface ChatTimelineItem { - val id: String - - data class User( - override val id: String, - val text: String, - val imageCount: Int = 0, - val imageUris: List = emptyList(), - ) : ChatTimelineItem - - data class Assistant( - override val id: String, - val text: String, - val thinking: String? = null, - val isThinkingExpanded: Boolean = false, - val isThinkingComplete: Boolean = false, - val isStreaming: Boolean, - ) : ChatTimelineItem - - data class Tool( - override val id: String, - val toolName: String, - val output: String, - val isCollapsed: Boolean, - val isStreaming: Boolean, - val isError: Boolean, - val arguments: Map = emptyMap(), - val editDiff: EditDiffInfo? = null, - val isDiffExpanded: Boolean = false, - ) : ChatTimelineItem -} - -/** - * Information about a file edit for diff display. - */ -data class EditDiffInfo( - val path: String, - val oldString: String, - val newString: String, -) - -class ChatViewModelFactory( - private val sessionController: SessionController, - private val imageEncoder: ImageEncoder? = null, -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - check(modelClass == ChatViewModel::class.java) { - "Unsupported ViewModel class: ${modelClass.name}" - } - - @Suppress("UNCHECKED_CAST") - return ChatViewModel( - sessionController = sessionController, - imageEncoder = imageEncoder, - ) as T - } -} - -private data class InitialLoadMetadata( - val modelInfo: ModelInfo?, - val thinkingLevel: String?, - val isStreaming: Boolean, - val steeringMode: String, - val followUpMode: String, - val sessionPath: String?, - val sessionName: String?, - val pendingMessageCount: Int, -) - -private data class DraftClearState( - val inputWasCleared: Boolean, - val imagesWereCleared: Boolean, -) - -private data class ThinkingDiagnosticsCounters( - val startEvents: Int = 0, - val deltaEvents: Int = 0, - val deltaChars: Int = 0, - val endEvents: Int = 0, - val endPayloadEvents: Int = 0, - val endPayloadChars: Int = 0, - val renderedThinkingUpdates: Int = 0, - val renderedThinkingCompleteEvents: Int = 0, -) - -private data class StreamingDeltaDiagnosticsCounters( - val messageUpdateEvents: Int = 0, - val assistantDeltaEvents: Int = 0, - val textDeltaEvents: Int = 0, - val thinkingDeltaEvents: Int = 0, - val assistantNonDeltaEvents: Int = 0, - val coalescedDeltaEvents: Int = 0, - val emittedImmediateDeltaEvents: Int = 0, - val emittedFlushedDeltaEvents: Int = 0, -) - -private data class HistoryMessageWindow( - val messages: List, - val absoluteOffset: Int, -) - -private fun historyWindowSignature(messages: List): String { - if (messages.isEmpty()) return "empty" - - val marker = - messages - .joinToString(separator = "|") { message -> - val role = message.stringField("role").orEmpty() - val entryId = message.stringField("entryId").orEmpty() - "$role:$entryId:${message.toString().hashCode()}" - } - - return "${messages.size}:$marker" -} - -private fun extractHistoryMessageWindow(data: JsonObject?): HistoryMessageWindow { - val rawMessages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) - val startIndex = (rawMessages.size - HISTORY_WINDOW_MAX_ITEMS).coerceAtLeast(0) - - val messages = - rawMessages - .drop(startIndex) - .mapNotNull { messageElement -> - runCatching { messageElement.jsonObject }.getOrNull() - } - - return HistoryMessageWindow( - messages = messages, - absoluteOffset = startIndex, - ) -} - -private fun parseHistoryItems( - messages: List, - absoluteIndexOffset: Int, - startIndex: Int = 0, - endExclusive: Int = messages.size, -): List { - if (messages.isEmpty()) { - return emptyList() - } - - val boundedStart = startIndex.coerceIn(0, messages.size) - val boundedEnd = endExclusive.coerceIn(boundedStart, messages.size) - - return (boundedStart until boundedEnd).mapNotNull { index -> - val message = messages[index] - val absoluteIndex = absoluteIndexOffset + index - - when (message.stringField("role")) { - "user" -> { - val content = message["content"] - val text = extractUserText(content) - val imageCount = extractUserImageCount(content) - ChatTimelineItem.User( - id = "history-user-$absoluteIndex", - text = text, - imageCount = imageCount, - ) - } - - "assistant" -> { - val text = extractAssistantText(message["content"]) - val thinking = extractAssistantThinking(message["content"]) - ChatTimelineItem.Assistant( - id = "history-assistant-$absoluteIndex", - text = text, - thinking = thinking, - isThinkingComplete = thinking != null, - isStreaming = false, - ) - } - - "toolResult" -> { - val output = extractToolOutput(message) - ChatTimelineItem.Tool( - id = "history-tool-$absoluteIndex", - toolName = message.stringField("toolName") ?: "tool", - output = output, - isCollapsed = output.length > 400, - isStreaming = false, - isError = message.booleanField("isError") ?: false, - arguments = emptyMap(), - editDiff = null, - ) - } - - else -> null - } - } -} - -private fun extractUserText(content: JsonElement?): String { - return when (content) { - null -> "" - is JsonObject -> content.stringField("text").orEmpty() - else -> { - runCatching { - when (content) { - is kotlinx.serialization.json.JsonPrimitive -> content.contentOrNull.orEmpty() - else -> { - content.jsonArray - .mapNotNull { block -> - block.jsonObject.takeIf { it.stringField("type") == "text" }?.stringField("text") - }.joinToString("\n") - } - } - }.getOrDefault("") - } - } -} - -private fun extractUserImageCount(content: JsonElement?): Int { - return runCatching { - when (content) { - null -> 0 - is kotlinx.serialization.json.JsonPrimitive -> 0 - is JsonObject -> { - val type = content.stringField("type")?.lowercase().orEmpty() - if ("image" in type) 1 else 0 - } - else -> { - content.jsonArray.count { block -> - val blockObject = runCatching { block.jsonObject }.getOrNull() ?: return@count false - val type = blockObject.stringField("type")?.lowercase().orEmpty() - type.contains("image") || - blockObject["image"] != null || - blockObject["imageUrl"] != null || - blockObject["image_url"] != null - } - } - } - }.getOrDefault(0) -} - -private fun extractAssistantText(content: JsonElement?): String { - val contentArray = runCatching { content?.jsonArray }.getOrNull() ?: return "" - return contentArray - .mapNotNull { block -> - val blockObject = block.jsonObject - if (blockObject.stringField("type") == "text") { - blockObject.stringField("text") - } else { - null - } - }.joinToString("\n") -} - -private fun extractAssistantThinking(content: JsonElement?): String? { - val contentArray = runCatching { content?.jsonArray }.getOrNull() ?: return null - val thinkingBlocks = - contentArray - .mapNotNull { block -> - val blockObject = block.jsonObject - if (blockObject.stringField("type") == "thinking") { - blockObject.stringField("thinking") - } else { - null - } - } - return thinkingBlocks.takeIf { it.isNotEmpty() }?.joinToString("\n") -} - -private fun extractToolOutput(source: JsonObject?): String { - return source?.let { jsonSource -> - val fromContent = - runCatching { - jsonSource["content"]?.jsonArray - ?.mapNotNull { block -> - val blockObject = block.jsonObject - if (blockObject.stringField("type") == "text") { - blockObject.stringField("text") - } else { - null - } - }?.joinToString("\n") - }.getOrNull() - - fromContent?.takeIf { it.isNotBlank() } ?: jsonSource.stringField("output").orEmpty() - }.orEmpty() -} - -private fun JsonObject.stringField(fieldName: String): String? { - return this[fieldName]?.jsonPrimitive?.contentOrNull -} - -private fun JsonObject.booleanField(fieldName: String): Boolean? { - return this[fieldName]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() -} - -private fun JsonObject.intField(fieldName: String): Int? { - return this[fieldName]?.jsonPrimitive?.intOrNull -} - -private fun JsonObject?.deliveryModeField( - camelCaseKey: String, - snakeCaseKey: String, -): String { - val value = - this?.get(camelCaseKey)?.jsonPrimitive?.contentOrNull - ?: this?.get(snakeCaseKey)?.jsonPrimitive?.contentOrNull - - return value?.takeIf { - it == ChatViewModel.DELIVERY_MODE_ALL || it == ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME - } ?: ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME -} - -private fun parseModelInfo(data: JsonObject?): ModelInfo? { - val model = data?.get("model") as? JsonObject ?: return null - return ModelInfo( - id = model.stringField("id") ?: "unknown", - name = model.stringField("name") ?: "Unknown Model", - provider = model.stringField("provider") ?: "unknown", - thinkingLevel = data.stringField("thinkingLevel") ?: "off", - contextWindow = model.intField("contextWindow"), - ) -} - -/** - * Extracts tool arguments from JSON object as a map of string keys to string values. - * Only extracts primitive string arguments for display purposes. - */ -private fun extractToolArguments(args: JsonObject?): Map { - if (args == null) return emptyMap() - return args - .mapNotNull { (key, value) -> - when { - value is kotlinx.serialization.json.JsonPrimitive && - value.isString -> key to value.content - else -> null - } - }.toMap() -} - -/** - * Extracts edit tool diff information from arguments. - * Returns null if not an edit tool or required fields are missing. - */ -@Suppress("ReturnCount") -private fun extractEditDiff(args: JsonObject?): EditDiffInfo? { - if (args == null) return null - val path = args.stringField("path") ?: return null - val oldString = args.stringField("oldString") ?: return null - val newString = args.stringField("newString") ?: return null - return EditDiffInfo( - path = path, - oldString = oldString, - newString = newString, - ) -} diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ImageEncoder.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ImageEncoder.kt index 62c2d20..a0c464e 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ImageEncoder.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ImageEncoder.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.util.Base64 +import androidx.core.net.toUri import com.ayagmar.pimobile.corerpc.ImagePayload /** @@ -12,7 +13,7 @@ import com.ayagmar.pimobile.corerpc.ImagePayload class ImageEncoder(private val context: Context) { fun encodeToPayload(pendingImage: PendingImage): ImagePayload? { return try { - val uri = Uri.parse(pendingImage.uri) + val uri = pendingImage.uri.toUri() val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return null diff --git a/app/src/main/java/com/ayagmar/pimobile/hosts/HostPairingPayload.kt b/app/src/main/java/com/ayagmar/pimobile/hosts/HostPairingPayload.kt new file mode 100644 index 0000000..c8bb72e --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/hosts/HostPairingPayload.kt @@ -0,0 +1,41 @@ +package com.ayagmar.pimobile.hosts + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +private const val PAIRING_PAYLOAD_TYPE = "pi-mobile-host" +private const val PAIRING_PAYLOAD_VERSION = 1 + +fun parseHostPairingPayload(rawValue: String): Result = + runCatching { + val payload = Json.parseToJsonElement(rawValue).jsonObject + require(payload.string("type") == PAIRING_PAYLOAD_TYPE) { "This QR code is not for Pi Mobile" } + require(payload.int("version") == PAIRING_PAYLOAD_VERSION) { "This pairing QR version is not supported" } + + val draft = + HostDraft( + name = payload.string("name").orEmpty(), + host = payload.string("host").orEmpty(), + port = payload.int("port")?.toString().orEmpty(), + useTls = payload.boolean("useTls") ?: false, + token = payload.string("token").orEmpty(), + ) + require(draft.token.isNotBlank()) { "The pairing QR does not contain a token" } + + when (val validation = draft.validate()) { + is HostValidationResult.Valid -> draft + is HostValidationResult.Invalid -> error(validation.reason) + } + } + +private fun Map.string(name: String): String? = + get(name)?.jsonPrimitive?.content + +private fun Map.int(name: String): Int? = + get(name)?.jsonPrimitive?.intOrNull + +private fun Map.boolean(name: String): Boolean? = + get(name)?.jsonPrimitive?.booleanOrNull diff --git a/app/src/main/java/com/ayagmar/pimobile/hosts/HostProfileStore.kt b/app/src/main/java/com/ayagmar/pimobile/hosts/HostProfileStore.kt index fc62fb8..9c5b259 100644 --- a/app/src/main/java/com/ayagmar/pimobile/hosts/HostProfileStore.kt +++ b/app/src/main/java/com/ayagmar/pimobile/hosts/HostProfileStore.kt @@ -2,6 +2,7 @@ package com.ayagmar.pimobile.hosts import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import org.json.JSONArray import org.json.JSONObject @@ -42,7 +43,7 @@ class SharedPreferencesHostProfileStore( } private fun persist(profiles: List) { - preferences.edit().putString(PROFILES_KEY, encodeProfiles(profiles)).apply() + preferences.edit { putString(PROFILES_KEY, encodeProfiles(profiles)) } } private fun encodeProfiles(profiles: List): String { diff --git a/app/src/main/java/com/ayagmar/pimobile/hosts/HostTokenStore.kt b/app/src/main/java/com/ayagmar/pimobile/hosts/HostTokenStore.kt index 96c3204..507c022 100644 --- a/app/src/main/java/com/ayagmar/pimobile/hosts/HostTokenStore.kt +++ b/app/src/main/java/com/ayagmar/pimobile/hosts/HostTokenStore.kt @@ -2,10 +2,21 @@ package com.ayagmar.pimobile.hosts import android.content.Context import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.core.content.edit +import java.io.File +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec interface HostTokenStore { + val requiresTokenReentry: Boolean + get() = false + fun hasToken(hostId: String): Boolean fun getToken(hostId: String): String? @@ -19,50 +30,102 @@ interface HostTokenStore { } class KeystoreHostTokenStore( - context: Context, + private val context: Context, + private val cipher: TokenCipher = TokenCipher(androidKeystoreKey()), ) : HostTokenStore { + private val migrationPreferences = + context.getSharedPreferences(MIGRATION_PREFS_FILE, Context.MODE_PRIVATE) private val preferences: SharedPreferences = - createEncryptedPreferences( - context = context, - fileName = TOKENS_PREFS_FILE, - ) + context.getSharedPreferences(TOKENS_PREFS_FILE, Context.MODE_PRIVATE) + + override val requiresTokenReentry: Boolean = resetLegacyTokensIfNeeded() - override fun hasToken(hostId: String): Boolean = preferences.contains(tokenKey(hostId)) + override fun hasToken(hostId: String): Boolean = getToken(hostId) != null - override fun getToken(hostId: String): String? = preferences.getString(tokenKey(hostId), null) + override fun getToken(hostId: String): String? { + val encrypted = preferences.getString(tokenKey(hostId), null) ?: return null + return runCatching { cipher.decrypt(encrypted) } + .onFailure { preferences.edit { remove(tokenKey(hostId)) } } + .getOrNull() + } override fun setToken( hostId: String, token: String, ) { - preferences.edit().putString(tokenKey(hostId), token).apply() + preferences.edit { putString(tokenKey(hostId), cipher.encrypt(token)) } } override fun clearToken(hostId: String) { - preferences.edit().remove(tokenKey(hostId)).apply() + preferences.edit { remove(tokenKey(hostId)) } + } + + private fun resetLegacyTokensIfNeeded(): Boolean { + if (migrationPreferences.getBoolean(MIGRATION_COMPLETE_KEY, false)) return false + + val legacyPreferencesFile = + File(context.applicationInfo.dataDir, "shared_prefs/$LEGACY_TOKENS_PREFS_FILE.xml") + val legacyPreferencesExist = legacyPreferencesFile.exists() + if (legacyPreferencesExist) { + context.deleteSharedPreferences(LEGACY_TOKENS_PREFS_FILE) + } + migrationPreferences.edit { putBoolean(MIGRATION_COMPLETE_KEY, true) } + return legacyPreferencesExist } private fun tokenKey(hostId: String): String = "token_$hostId" companion object { - private const val TOKENS_PREFS_FILE = "pi_mobile_host_tokens_secure" - - private fun createEncryptedPreferences( - context: Context, - fileName: String, - ): SharedPreferences { - val masterKey = - MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - return EncryptedSharedPreferences.create( - context, - fileName, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + private const val KEY_ALIAS = "pi_mobile_host_tokens_v2" + private const val LEGACY_TOKENS_PREFS_FILE = "pi_mobile_host_tokens_secure" + private const val TOKENS_PREFS_FILE = "pi_mobile_host_tokens_v2" + private const val MIGRATION_PREFS_FILE = "pi_mobile_token_migration" + private const val MIGRATION_COMPLETE_KEY = "legacy_tokens_reset_v2" + private const val KEY_SIZE_BITS = 256 + + private fun androidKeystoreKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + keyGenerator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_SIZE_BITS) + .build(), ) + return keyGenerator.generateKey() } } } + +class TokenCipher( + private val key: SecretKey, +) { + fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val payload = cipher.iv + ciphertext + return Base64.getEncoder().encodeToString(payload) + } + + fun decrypt(payload: String): String { + val bytes = Base64.getDecoder().decode(payload) + require(bytes.size > IV_LENGTH_BYTES) { "Encrypted token payload is invalid" } + val iv = bytes.copyOfRange(0, IV_LENGTH_BYTES) + val ciphertext = bytes.copyOfRange(IV_LENGTH_BYTES, bytes.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, iv)) + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private companion object { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH_BYTES = 12 + private const val TAG_LENGTH_BITS = 128 + } +} diff --git a/app/src/main/java/com/ayagmar/pimobile/hosts/HostsViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/hosts/HostsViewModel.kt index 039f7e6..b3cd763 100644 --- a/app/src/main/java/com/ayagmar/pimobile/hosts/HostsViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/hosts/HostsViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.UUID class HostsViewModel( @@ -51,6 +52,7 @@ class HostsViewModel( profiles = items, errorMessage = null, diagnosticResults = emptyMap(), + requiresTokenReentry = tokenStore.requiresTokenReentry, ) } } @@ -114,7 +116,10 @@ class HostsViewModel( } } - fun saveHost(draft: HostDraft) { + fun saveHost( + draft: HostDraft, + onSaved: () -> Unit = {}, + ) { when (val validation = draft.validate()) { is HostValidationResult.Invalid -> { _uiState.update { state -> state.copy(errorMessage = validation.reason) } @@ -137,12 +142,15 @@ class HostsViewModel( return } - viewModelScope.launch(Dispatchers.IO) { - profileStore.upsert(profile) - if (hasProvidedToken) { - tokenStore.setToken(profile.id, draft.token) + viewModelScope.launch { + withContext(Dispatchers.IO) { + profileStore.upsert(profile) + if (hasProvidedToken) { + tokenStore.setToken(profile.id, draft.token) + } } refresh() + onSaved() } } } @@ -162,6 +170,7 @@ data class HostsUiState( val profiles: List = emptyList(), val errorMessage: String? = null, val diagnosticResults: Map = emptyMap(), + val requiresTokenReentry: Boolean = false, ) class HostsViewModelFactory( diff --git a/app/src/main/java/com/ayagmar/pimobile/hosts/RecoveryMessage.kt b/app/src/main/java/com/ayagmar/pimobile/hosts/RecoveryMessage.kt new file mode 100644 index 0000000..e7aa7f4 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/hosts/RecoveryMessage.kt @@ -0,0 +1,36 @@ +package com.ayagmar.pimobile.hosts + +data class RecoveryMessage( + val title: String, + val explanation: String, + val actionLabel: String, +) + +fun DiagnosticsResult.toRecoveryMessage(): RecoveryMessage { + return when (this) { + is DiagnosticsResult.Success -> + RecoveryMessage( + title = "Bridge ready", + explanation = "Network, authentication, and Pi RPC checks passed.", + actionLabel = "Open sessions", + ) + is DiagnosticsResult.NetworkError -> + RecoveryMessage( + title = "Cannot reach the bridge", + explanation = "Check Tailscale, the computer address, port, and bridge process.", + actionLabel = "Try again", + ) + is DiagnosticsResult.AuthError -> + RecoveryMessage( + title = "Authentication rejected", + explanation = "Enter the bridge token again. Stored tokens are never displayed.", + actionLabel = "Update token", + ) + is DiagnosticsResult.RpcError -> + RecoveryMessage( + title = "Pi is not ready", + explanation = "Verify Pi 0.80.6 or newer is installed and its model credentials are configured.", + actionLabel = "Test Pi again", + ) + } +} diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcAuxiliaryResponseMappers.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcAuxiliaryResponseMappers.kt new file mode 100644 index 0000000..92593d6 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcAuxiliaryResponseMappers.kt @@ -0,0 +1,205 @@ +package com.ayagmar.pimobile.sessions + +import com.ayagmar.pimobile.corerpc.AvailableModel +import com.ayagmar.pimobile.corerpc.BashResult +import com.ayagmar.pimobile.corerpc.SessionStats +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.math.roundToInt + +internal fun parseBashResult(data: JsonObject?): BashResult { + return BashResult( + output = data?.stringField("output") ?: "", + exitCode = data?.get("exitCode")?.jsonPrimitive?.contentOrNull?.toIntOrNull() ?: -1, + // pi RPC uses "truncated" and "fullOutputPath". + wasTruncated = data?.booleanField("truncated") ?: data?.booleanField("wasTruncated") ?: false, + fullLogPath = data?.stringField("fullOutputPath") ?: data?.stringField("fullLogPath"), + ) +} + +@Suppress("MagicNumber", "LongMethod") +internal fun parseSessionStats(data: JsonObject?): SessionStats { + val tokens = runCatching { data?.get("tokens")?.jsonObject }.getOrNull() + + val inputTokens = + coalesceLong( + tokens?.longField("input"), + data?.longField("inputTokens"), + ) + val outputTokens = + coalesceLong( + tokens?.longField("output"), + data?.longField("outputTokens"), + ) + val cacheReadTokens = + coalesceLong( + tokens?.longField("cacheRead"), + data?.longField("cacheReadTokens"), + ) + val cacheWriteTokens = + coalesceLong( + tokens?.longField("cacheWrite"), + data?.longField("cacheWriteTokens"), + ) + val totalCost = + coalesceDouble( + data?.doubleField("cost"), + data?.doubleField("totalCost"), + ) + + val messageCount = + coalesceInt( + data?.intField("totalMessages"), + data?.intField("messageCount"), + ) + val userMessageCount = + coalesceInt( + data?.intField("userMessages"), + data?.intField("userMessageCount"), + ) + val assistantMessageCount = + coalesceInt( + data?.intField("assistantMessages"), + data?.intField("assistantMessageCount"), + ) + val toolResultCount = + coalesceInt( + data?.intField("toolResults"), + data?.intField("toolResultCount"), + data?.intField("toolCalls"), + ) + val sessionPath = + coalesceString( + data?.stringField("sessionFile"), + data?.stringField("sessionPath"), + ) + val compactionCount = + coalesceInt( + data?.intField("compactions"), + data?.intField("compactionCount"), + data?.intField("autoCompactions"), + ) + + val contextUsage = runCatching { data?.get("contextUsage")?.jsonObject }.getOrNull() + val context = runCatching { data?.get("context")?.jsonObject }.getOrNull() + val contextUsedTokens = + coalesceLongOrNull( + contextUsage?.longField("tokens"), + context?.longField("used"), + context?.longField("tokens"), + context?.longField("current"), + data?.longField("contextUsedTokens"), + data?.longField("contextTokens"), + data?.longField("activeContextTokens"), + ) + val contextWindowTokens = + coalesceLongOrNull( + contextUsage?.longField("contextWindow"), + context?.longField("window"), + context?.longField("max"), + data?.longField("contextWindow"), + ) + val contextUsagePercent = + coalesceIntOrNull( + contextUsage?.intField("percent"), + contextUsage?.doubleField("percent")?.roundToInt(), + context?.intField("percent"), + context?.doubleField("percent")?.roundToInt(), + data?.intField("contextPercent"), + data?.doubleField("contextPercent")?.roundToInt(), + data?.intField("contextUsagePercent"), + data?.doubleField("contextUsagePercent")?.roundToInt(), + ) + + return SessionStats( + inputTokens = inputTokens, + outputTokens = outputTokens, + cacheReadTokens = cacheReadTokens, + cacheWriteTokens = cacheWriteTokens, + totalCost = totalCost, + messageCount = messageCount, + userMessageCount = userMessageCount, + assistantMessageCount = assistantMessageCount, + toolResultCount = toolResultCount, + sessionPath = sessionPath, + compactionCount = compactionCount, + contextUsedTokens = contextUsedTokens, + contextWindowTokens = contextWindowTokens, + contextUsagePercent = contextUsagePercent, + ) +} + +internal fun parseAvailableModels(data: JsonObject?): List { + val models = runCatching { data?.get("models")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + + return models.mapNotNull { modelElement -> + val modelObject = runCatching { modelElement.jsonObject }.getOrNull() ?: return@mapNotNull null + val id = modelObject.stringField("id") ?: return@mapNotNull null + val cost = runCatching { modelObject["cost"]?.jsonObject }.getOrNull() + + AvailableModel( + id = id, + name = modelObject.stringField("name") ?: id, + provider = modelObject.stringField("provider") ?: "unknown", + contextWindow = modelObject.intField("contextWindow"), + maxOutputTokens = modelObject.intField("maxTokens") ?: modelObject.intField("maxOutputTokens"), + supportsThinking = + modelObject.booleanField("reasoning") + ?: modelObject.booleanField("supportsThinking") + ?: false, + inputCostPer1k = cost?.doubleField("input") ?: modelObject.doubleField("inputCostPer1k"), + outputCostPer1k = cost?.doubleField("output") ?: modelObject.doubleField("outputCostPer1k"), + ) + } +} + +internal fun coalesceLong(vararg values: Long?): Long { + return values.firstOrNull { it != null } ?: 0L +} + +internal fun coalesceLongOrNull(vararg values: Long?): Long? { + return values.firstOrNull { it != null } +} + +internal fun coalesceInt(vararg values: Int?): Int { + return values.firstOrNull { it != null } ?: 0 +} + +internal fun coalesceIntOrNull(vararg values: Int?): Int? { + return values.firstOrNull { it != null } +} + +internal fun coalesceDouble(vararg values: Double?): Double { + return values.firstOrNull { it != null } ?: 0.0 +} + +internal fun coalesceString(vararg values: String?): String? { + return values.firstOrNull { !it.isNullOrBlank() } +} + +private fun JsonObject?.stringField(fieldName: String): String? { + return runCatching { this?.get(fieldName)?.jsonPrimitive?.contentOrNull }.getOrNull() +} + +private fun JsonObject?.booleanField(fieldName: String): Boolean? { + return runCatching { this?.get(fieldName)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull() +} + +private fun JsonObject?.longField(fieldName: String): Long? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toLongOrNull() +} + +private fun JsonObject?.intField(fieldName: String): Int? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toIntOrNull() +} + +private fun JsonObject?.doubleField(fieldName: String): Double? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toDoubleOrNull() +} diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 43afaff..dd48f16 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -8,7 +8,7 @@ import com.ayagmar.pimobile.corenet.WebSocketTarget import com.ayagmar.pimobile.corerpc.AbortBashCommand import com.ayagmar.pimobile.corerpc.AbortCommand import com.ayagmar.pimobile.corerpc.AbortRetryCommand -import com.ayagmar.pimobile.corerpc.AgentEndEvent +import com.ayagmar.pimobile.corerpc.AgentSettledEvent import com.ayagmar.pimobile.corerpc.AgentStartEvent import com.ayagmar.pimobile.corerpc.AvailableModel import com.ayagmar.pimobile.corerpc.BashCommand @@ -41,7 +41,6 @@ import com.ayagmar.pimobile.corerpc.SetSteeringModeCommand import com.ayagmar.pimobile.corerpc.SetThinkingLevelCommand import com.ayagmar.pimobile.corerpc.SteerCommand import com.ayagmar.pimobile.corerpc.SwitchSessionCommand -import com.ayagmar.pimobile.corerpc.TurnEndEvent import com.ayagmar.pimobile.coresessions.SessionRecord import com.ayagmar.pimobile.hosts.HostProfile import kotlinx.coroutines.CoroutineScope @@ -63,16 +62,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.util.UUID -import kotlin.math.roundToInt @Suppress("TooManyFunctions", "LargeClass") class RpcSessionController( @@ -87,7 +83,11 @@ class RpcSessionController( private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) private val _isStreaming = MutableStateFlow(false) private val _sessionChanged = MutableSharedFlow(extraBufferCapacity = 16) + private val _timelineInvalidated = MutableSharedFlow(extraBufferCapacity = 16) + private val _syncMetrics = MutableStateFlow(SessionSyncMetrics()) + private val entryProjection = SessionEntryProjection() + private var projectedMessagesResponse: RpcResponse? = null private var activeConnection: PiRpcConnection? = null private var activeContext: ActiveConnectionContext? = null private var transportPreference: TransportPreference = TransportPreference.AUTO @@ -95,17 +95,24 @@ class RpcSessionController( private var connectionStateJob: Job? = null private var streamingMonitorJob: Job? = null private var resyncMonitorJob: Job? = null + private var invalidationMonitorJob: Job? = null private var reconnectRecoveryJob: Job? = null override val rpcEvents: SharedFlow = _rpcEvents override val connectionState: StateFlow = _connectionState.asStateFlow() override val isStreaming: StateFlow = _isStreaming.asStateFlow() override val sessionChanged: SharedFlow = _sessionChanged + override val timelineInvalidated: SharedFlow = _timelineInvalidated + override val syncMetrics: StateFlow = _syncMetrics.asStateFlow() override fun setTransportPreference(preference: TransportPreference) { transportPreference = preference } + override fun recordSafetyPoll() { + _syncMetrics.value = _syncMetrics.value.copy(safetyPolls = _syncMetrics.value.safetyPolls + 1) + } + override fun getTransportPreference(): TransportPreference = transportPreference override fun getEffectiveTransportPreference(): TransportPreference { @@ -169,6 +176,7 @@ class RpcSessionController( } val newPath = refreshCurrentSessionPath(connection) + resetSessionProjection() _sessionChanged.emit(newPath) newPath } @@ -179,7 +187,8 @@ class RpcSessionController( return mutex.withLock { runCatching { val connection = ensureActiveConnection() - connection.requestMessages().requireSuccess("Failed to load messages") + projectedMessagesResponse + ?: connection.requestMessages().requireSuccess("Failed to load messages") } } } @@ -299,6 +308,16 @@ class RpcSessionController( return mutex.withLock { runCatching { val connection = ensureActiveConnection() + val activeSessionPath = refreshCurrentSessionPath(connection) + if (sessionPath.isNullOrBlank() || sessionPath == activeSessionPath) { + val response = connection.requestTree().requireSuccess("Failed to load active session tree") + return@runCatching parseRpcSessionTreeSnapshot( + data = response.data, + sessionPath = activeSessionPath.orEmpty(), + filter = filter, + ) + } + val bridgePayload = buildJsonObject { put("type", BRIDGE_GET_SESSION_TREE_TYPE) @@ -380,6 +399,7 @@ class RpcSessionController( } val newPath = refreshCurrentSessionPath(connection) + resetSessionProjection() _sessionChanged.emit(newPath) return newPath } @@ -577,6 +597,7 @@ class RpcSessionController( newSessionResponse.requireNotCancelled("New session was cancelled") val newPath = refreshCurrentSessionPath(connection) + resetSessionProjection() _sessionChanged.emit(newPath) Unit } @@ -642,6 +663,7 @@ class RpcSessionController( ) val sessionPath = bridgeResponse.payload.stringField("sessionPath") + resetSessionProjection() _sessionChanged.emit(sessionPath) sessionPath } @@ -914,11 +936,13 @@ class RpcSessionController( connectionStateJob?.cancel() streamingMonitorJob?.cancel() resyncMonitorJob?.cancel() + invalidationMonitorJob?.cancel() reconnectRecoveryJob?.cancel() rpcEventsJob = null connectionStateJob = null streamingMonitorJob = null resyncMonitorJob = null + invalidationMonitorJob = null reconnectRecoveryJob = null activeConnection?.disconnect() @@ -935,6 +959,7 @@ class RpcSessionController( connectionStateJob?.cancel() streamingMonitorJob?.cancel() resyncMonitorJob?.cancel() + invalidationMonitorJob?.cancel() reconnectRecoveryJob?.cancel() reconnectRecoveryJob = null @@ -979,22 +1004,85 @@ class RpcSessionController( connection.rpcEvents.collect { event -> when (event) { is AgentStartEvent -> _isStreaming.value = true - is AgentEndEvent, - is TurnEndEvent, - -> _isStreaming.value = false + is AgentSettledEvent -> _isStreaming.value = false else -> Unit } } } - resyncMonitorJob = - scope.launch { - connection.resyncEvents.collect { snapshot -> - val isStreaming = snapshot.stateResponse.data.booleanField("isStreaming") ?: false - _isStreaming.value = isStreaming + resyncMonitorJob = observeEntryResync(connection) + invalidationMonitorJob = observeSessionInvalidations(connection) + } + + private fun resetSessionProjection() { + entryProjection.reset() + projectedMessagesResponse = null + } + + private fun observeEntryResync(connection: PiRpcConnection): Job { + return scope.launch { + connection.resyncEvents.collect { snapshot -> + val isStreaming = snapshot.stateResponse.data.booleanField("isStreaming") ?: false + _isStreaming.value = isStreaming + applyEntrySnapshot(connection, snapshot.entriesResponse, snapshot.fullRebuild) + } + } + } + + private suspend fun applyEntrySnapshot( + connection: PiRpcConnection, + response: RpcResponse, + fullRebuild: Boolean, + ) { + val entryCount = runCatching { response.data?.get("entries")?.jsonArray?.size }.getOrNull() ?: 0 + val update = entryProjection.apply(response.data, fullRebuild) + if (update is ProjectionUpdate.Applied) { + projectedMessagesResponse = update.toMessagesResponse() + _syncMetrics.value = + _syncMetrics.value.copy( + fullRebuilds = _syncMetrics.value.fullRebuilds + if (fullRebuild) 1 else 0, + incrementalEntries = _syncMetrics.value.incrementalEntries + if (fullRebuild) 0 else entryCount, + ) + _timelineInvalidated.emit(Unit) + return + } + + val rebuildResponse = connection.requestEntries().requireSuccess("Failed to rebuild session entries") + val rebuilt = entryProjection.apply(rebuildResponse.data, fullRebuild = true) + _syncMetrics.value = _syncMetrics.value.copy(fullRebuilds = _syncMetrics.value.fullRebuilds + 1) + projectedMessagesResponse = + if (rebuilt is ProjectionUpdate.Applied) { + rebuilt.toMessagesResponse() + } else { + connection.requestMessages().requireSuccess("Failed to rebuild session timeline") + } + _timelineInvalidated.emit(Unit) + } + + private fun ProjectionUpdate.Applied.toMessagesResponse(): RpcResponse { + return RpcResponse( + type = "response", + command = "get_messages", + success = true, + data = buildJsonObject { put("messages", messages) }, + ) + } + + private fun observeSessionInvalidations(connection: PiRpcConnection): Job { + return scope.launch { + connection.bridgeEvents.collect { event -> + if (event.type == BRIDGE_SESSION_INVALIDATED_TYPE) { + runCatching { connection.resync() } + .onFailure { error -> + Log.w( + TRANSPORT_LOG_TAG, + "Session invalidation resync failed: ${error.message ?: "unknown"}", + ) + } } } + } } private fun scheduleReconnectRecovery(connection: PiRpcConnection) { @@ -1095,6 +1183,7 @@ class RpcSessionController( private const val BRIDGE_SESSION_IMPORTED_TYPE = "bridge_session_imported" private const val BRIDGE_NAVIGATE_TREE_TYPE = "bridge_navigate_tree" private const val BRIDGE_TREE_NAVIGATION_RESULT_TYPE = "bridge_tree_navigation_result" + private const val BRIDGE_SESSION_INVALIDATED_TYPE = "bridge_session_invalidated" private const val EVENT_BUFFER_CAPACITY = 256 private const val DEFAULT_TIMEOUT_MS = 10_000L private const val BASH_TIMEOUT_MS = 60_000L @@ -1145,315 +1234,10 @@ private fun RpcResponse.requireNotCancelled(defaultError: String): RpcResponse { return this } -private fun parseForkableMessages(data: JsonObject?): List { - val messages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) - - return messages.mapNotNull { messageElement -> - val messageObject = messageElement.jsonObject - val entryId = messageObject.stringField("entryId") ?: return@mapNotNull null - // pi RPC currently returns "text" for fork messages; keep "preview" as fallback. - val preview = messageObject.stringField("text") ?: messageObject.stringField("preview") ?: "(no preview)" - val timestamp = messageObject["timestamp"]?.jsonPrimitive?.contentOrNull?.toLongOrNull() - - ForkableMessage( - entryId = entryId, - preview = preview, - timestamp = timestamp, - ) - } -} - -private fun parseSessionTreeSnapshot(payload: JsonObject): SessionTreeSnapshot { - val sessionPath = payload.stringField("sessionPath") ?: error("Session tree response missing sessionPath") - val rootIds = - runCatching { - payload["rootIds"]?.jsonArray?.mapNotNull { element -> - element.jsonPrimitive.contentOrNull - } - }.getOrNull() ?: emptyList() - - val entries = - runCatching { - payload["entries"]?.jsonArray?.mapNotNull { element -> - val entryObject = element.jsonObject - val entryId = entryObject.stringField("entryId") ?: return@mapNotNull null - SessionTreeEntry( - entryId = entryId, - parentId = entryObject.stringField("parentId"), - entryType = entryObject.stringField("entryType") ?: "entry", - role = entryObject.stringField("role"), - timestamp = entryObject.stringField("timestamp"), - preview = entryObject.stringField("preview") ?: "entry", - label = entryObject.stringField("label"), - isBookmarked = entryObject.booleanField("isBookmarked") ?: false, - ) - } - }.getOrNull() ?: emptyList() - - return SessionTreeSnapshot( - sessionPath = sessionPath, - rootIds = rootIds, - currentLeafId = payload.stringField("currentLeafId"), - entries = entries, - ) -} - -private fun parseSessionFreshnessSnapshot(payload: JsonObject): SessionFreshnessSnapshot { - val sessionPath = payload.stringField("sessionPath") ?: error("Session freshness response missing sessionPath") - val cwd = payload.stringField("cwd") ?: error("Session freshness response missing cwd") - - val fingerprintPayload = runCatching { payload["fingerprint"]?.jsonObject }.getOrNull() - val lockPayload = runCatching { payload["lock"]?.jsonObject }.getOrNull() - - val fingerprint = - SessionFreshnessFingerprint( - mtimeMs = fingerprintPayload.longField("mtimeMs") ?: 0L, - sizeBytes = fingerprintPayload.longField("sizeBytes") ?: 0L, - entryCount = fingerprintPayload.intField("entryCount") ?: 0, - lastEntryId = fingerprintPayload.stringField("lastEntryId"), - lastEntriesHash = fingerprintPayload.stringField("lastEntriesHash"), - ) - - val lock = - SessionLockMetadata( - cwdOwnerClientId = lockPayload.stringField("cwdOwnerClientId"), - sessionOwnerClientId = lockPayload.stringField("sessionOwnerClientId"), - isCurrentClientCwdOwner = lockPayload.booleanField("isCurrentClientCwdOwner") ?: false, - isCurrentClientSessionOwner = lockPayload.booleanField("isCurrentClientSessionOwner") ?: false, - ) - - return SessionFreshnessSnapshot( - sessionPath = sessionPath, - cwd = cwd, - fingerprint = fingerprint, - lock = lock, - ) -} - -private fun parseTreeNavigationResult(payload: JsonObject): TreeNavigationResult { - return TreeNavigationResult( - cancelled = payload.booleanField("cancelled") ?: false, - editorText = payload.stringField("editorText"), - currentLeafId = payload.stringField("currentLeafId"), - sessionPath = payload.stringField("sessionPath"), - ) -} - private fun JsonObject?.stringField(fieldName: String): String? { - val jsonObject = this ?: return null - return jsonObject[fieldName]?.jsonPrimitive?.contentOrNull + return runCatching { this?.get(fieldName)?.jsonPrimitive?.contentOrNull }.getOrNull() } private fun JsonObject?.booleanField(fieldName: String): Boolean? { - val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null - return value.toBooleanStrictOrNull() -} - -private fun parseModelInfo(data: JsonObject?): ModelInfo? { - val nestedModel = data?.get("model") as? JsonObject - val model = nestedModel ?: data?.takeIf { it.stringField("id") != null } ?: return null - - return ModelInfo( - id = model.stringField("id") ?: "unknown", - name = model.stringField("name") ?: "Unknown Model", - provider = model.stringField("provider") ?: "unknown", - thinkingLevel = data.stringField("thinkingLevel") ?: "off", - contextWindow = model.intField("contextWindow"), - ) -} - -private fun parseSlashCommands(data: JsonObject?): List { - val commands = runCatching { data?.get("commands")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) - - return commands.mapNotNull { commandElement -> - val commandObject = commandElement.jsonObject - val name = commandObject.stringField("name") ?: return@mapNotNull null - SlashCommandInfo( - name = name, - description = commandObject.stringField("description"), - source = commandObject.stringField("source") ?: "unknown", - location = commandObject.stringField("location"), - path = commandObject.stringField("path"), - ) - } -} - -private fun parseBashResult(data: JsonObject?): BashResult { - return BashResult( - output = data?.stringField("output") ?: "", - exitCode = data?.get("exitCode")?.jsonPrimitive?.contentOrNull?.toIntOrNull() ?: -1, - // pi RPC uses "truncated" and "fullOutputPath". - wasTruncated = data?.booleanField("truncated") ?: data?.booleanField("wasTruncated") ?: false, - fullLogPath = data?.stringField("fullOutputPath") ?: data?.stringField("fullLogPath"), - ) -} - -@Suppress("MagicNumber", "LongMethod") -private fun parseSessionStats(data: JsonObject?): SessionStats { - val tokens = runCatching { data?.get("tokens")?.jsonObject }.getOrNull() - - val inputTokens = - coalesceLong( - tokens?.longField("input"), - data?.longField("inputTokens"), - ) - val outputTokens = - coalesceLong( - tokens?.longField("output"), - data?.longField("outputTokens"), - ) - val cacheReadTokens = - coalesceLong( - tokens?.longField("cacheRead"), - data?.longField("cacheReadTokens"), - ) - val cacheWriteTokens = - coalesceLong( - tokens?.longField("cacheWrite"), - data?.longField("cacheWriteTokens"), - ) - val totalCost = - coalesceDouble( - data?.doubleField("cost"), - data?.doubleField("totalCost"), - ) - - val messageCount = - coalesceInt( - data?.intField("totalMessages"), - data?.intField("messageCount"), - ) - val userMessageCount = - coalesceInt( - data?.intField("userMessages"), - data?.intField("userMessageCount"), - ) - val assistantMessageCount = - coalesceInt( - data?.intField("assistantMessages"), - data?.intField("assistantMessageCount"), - ) - val toolResultCount = - coalesceInt( - data?.intField("toolResults"), - data?.intField("toolResultCount"), - data?.intField("toolCalls"), - ) - val sessionPath = - coalesceString( - data?.stringField("sessionFile"), - data?.stringField("sessionPath"), - ) - val compactionCount = - coalesceInt( - data?.intField("compactions"), - data?.intField("compactionCount"), - data?.intField("autoCompactions"), - ) - - val context = runCatching { data?.get("context")?.jsonObject }.getOrNull() - val contextUsedTokens = - coalesceLongOrNull( - context?.longField("used"), - context?.longField("tokens"), - context?.longField("current"), - data?.longField("contextUsedTokens"), - data?.longField("contextTokens"), - data?.longField("activeContextTokens"), - ) - val contextWindowTokens = - coalesceLongOrNull( - context?.longField("window"), - context?.longField("max"), - data?.longField("contextWindow"), - ) - val contextUsagePercent = - coalesceIntOrNull( - context?.intField("percent"), - context?.doubleField("percent")?.roundToInt(), - data?.intField("contextPercent"), - data?.doubleField("contextPercent")?.roundToInt(), - data?.intField("contextUsagePercent"), - data?.doubleField("contextUsagePercent")?.roundToInt(), - ) - - return SessionStats( - inputTokens = inputTokens, - outputTokens = outputTokens, - cacheReadTokens = cacheReadTokens, - cacheWriteTokens = cacheWriteTokens, - totalCost = totalCost, - messageCount = messageCount, - userMessageCount = userMessageCount, - assistantMessageCount = assistantMessageCount, - toolResultCount = toolResultCount, - sessionPath = sessionPath, - compactionCount = compactionCount, - contextUsedTokens = contextUsedTokens, - contextWindowTokens = contextWindowTokens, - contextUsagePercent = contextUsagePercent, - ) -} - -private fun parseAvailableModels(data: JsonObject?): List { - val models = runCatching { data?.get("models")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) - - return models.mapNotNull { modelElement -> - val modelObject = runCatching { modelElement.jsonObject }.getOrNull() ?: return@mapNotNull null - val id = modelObject.stringField("id") ?: return@mapNotNull null - val cost = runCatching { modelObject["cost"]?.jsonObject }.getOrNull() - - AvailableModel( - id = id, - name = modelObject.stringField("name") ?: id, - provider = modelObject.stringField("provider") ?: "unknown", - contextWindow = modelObject.intField("contextWindow"), - maxOutputTokens = modelObject.intField("maxTokens") ?: modelObject.intField("maxOutputTokens"), - supportsThinking = - modelObject.booleanField("reasoning") - ?: modelObject.booleanField("supportsThinking") - ?: false, - inputCostPer1k = cost?.doubleField("input") ?: modelObject.doubleField("inputCostPer1k"), - outputCostPer1k = cost?.doubleField("output") ?: modelObject.doubleField("outputCostPer1k"), - ) - } -} - -private fun coalesceLong(vararg values: Long?): Long { - return values.firstOrNull { it != null } ?: 0L -} - -private fun coalesceLongOrNull(vararg values: Long?): Long? { - return values.firstOrNull { it != null } -} - -private fun coalesceInt(vararg values: Int?): Int { - return values.firstOrNull { it != null } ?: 0 -} - -private fun coalesceIntOrNull(vararg values: Int?): Int? { - return values.firstOrNull { it != null } -} - -private fun coalesceDouble(vararg values: Double?): Double { - return values.firstOrNull { it != null } ?: 0.0 -} - -private fun coalesceString(vararg values: String?): String? { - return values.firstOrNull { !it.isNullOrBlank() } -} - -private fun JsonObject?.longField(fieldName: String): Long? { - val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null - return value.toLongOrNull() -} - -private fun JsonObject?.intField(fieldName: String): Int? { - val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null - return value.toIntOrNull() -} - -private fun JsonObject?.doubleField(fieldName: String): Double? { - val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null - return value.toDoubleOrNull() + return runCatching { this?.get(fieldName)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull() } diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionResponseMappers.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionResponseMappers.kt new file mode 100644 index 0000000..fa7fbae --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionResponseMappers.kt @@ -0,0 +1,151 @@ +package com.ayagmar.pimobile.sessions + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal fun parseForkableMessages(data: JsonObject?): List { + val messages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + + return messages.mapNotNull { messageElement -> + val messageObject = messageElement.jsonObject + val entryId = messageObject.stringField("entryId") ?: return@mapNotNull null + // pi RPC currently returns "text" for fork messages; keep "preview" as fallback. + val preview = messageObject.stringField("text") ?: messageObject.stringField("preview") ?: "(no preview)" + val timestamp = messageObject["timestamp"]?.jsonPrimitive?.contentOrNull?.toLongOrNull() + + ForkableMessage( + entryId = entryId, + preview = preview, + timestamp = timestamp, + ) + } +} + +internal fun parseSessionTreeSnapshot(payload: JsonObject): SessionTreeSnapshot { + val sessionPath = payload.stringField("sessionPath") ?: error("Session tree response missing sessionPath") + val rootIds = + runCatching { + payload["rootIds"]?.jsonArray?.mapNotNull { element -> + element.jsonPrimitive.contentOrNull + } + }.getOrNull() ?: emptyList() + + val entries = + runCatching { + payload["entries"]?.jsonArray?.mapNotNull { element -> + val entryObject = element.jsonObject + val entryId = entryObject.stringField("entryId") ?: return@mapNotNull null + SessionTreeEntry( + entryId = entryId, + parentId = entryObject.stringField("parentId"), + entryType = entryObject.stringField("entryType") ?: "entry", + role = entryObject.stringField("role"), + timestamp = entryObject.stringField("timestamp"), + preview = entryObject.stringField("preview") ?: "entry", + label = entryObject.stringField("label"), + isBookmarked = entryObject.booleanField("isBookmarked") ?: false, + ) + } + }.getOrNull() ?: emptyList() + + return SessionTreeSnapshot( + sessionPath = sessionPath, + rootIds = rootIds, + currentLeafId = payload.stringField("currentLeafId"), + entries = entries, + ) +} + +internal fun parseSessionFreshnessSnapshot(payload: JsonObject): SessionFreshnessSnapshot { + val sessionPath = payload.stringField("sessionPath") ?: error("Session freshness response missing sessionPath") + val cwd = payload.stringField("cwd") ?: error("Session freshness response missing cwd") + + val fingerprintPayload = runCatching { payload["fingerprint"]?.jsonObject }.getOrNull() + val lockPayload = runCatching { payload["lock"]?.jsonObject }.getOrNull() + + val fingerprint = + SessionFreshnessFingerprint( + mtimeMs = fingerprintPayload.longField("mtimeMs") ?: 0L, + sizeBytes = fingerprintPayload.longField("sizeBytes") ?: 0L, + entryCount = fingerprintPayload.intField("entryCount") ?: 0, + lastEntryId = fingerprintPayload.stringField("lastEntryId"), + lastEntriesHash = fingerprintPayload.stringField("lastEntriesHash"), + ) + + val lock = + SessionLockMetadata( + cwdOwnerClientId = lockPayload.stringField("cwdOwnerClientId"), + sessionOwnerClientId = lockPayload.stringField("sessionOwnerClientId"), + isCurrentClientCwdOwner = lockPayload.booleanField("isCurrentClientCwdOwner") ?: false, + isCurrentClientSessionOwner = lockPayload.booleanField("isCurrentClientSessionOwner") ?: false, + ) + + return SessionFreshnessSnapshot( + sessionPath = sessionPath, + cwd = cwd, + fingerprint = fingerprint, + lock = lock, + ) +} + +internal fun parseTreeNavigationResult(payload: JsonObject): TreeNavigationResult { + return TreeNavigationResult( + cancelled = payload.booleanField("cancelled") ?: false, + editorText = payload.stringField("editorText"), + currentLeafId = payload.stringField("currentLeafId"), + sessionPath = payload.stringField("sessionPath"), + ) +} + +private fun JsonObject?.stringField(fieldName: String): String? { + val jsonObject = this ?: return null + return jsonObject[fieldName]?.jsonPrimitive?.contentOrNull +} + +private fun JsonObject?.booleanField(fieldName: String): Boolean? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toBooleanStrictOrNull() +} + +internal fun parseModelInfo(data: JsonObject?): ModelInfo? { + val nestedModel = data?.get("model") as? JsonObject + val model = nestedModel ?: data?.takeIf { it.stringField("id") != null } ?: return null + + return ModelInfo( + id = model.stringField("id") ?: "unknown", + name = model.stringField("name") ?: "Unknown Model", + provider = model.stringField("provider") ?: "unknown", + thinkingLevel = data.stringField("thinkingLevel") ?: "off", + contextWindow = model.intField("contextWindow"), + ) +} + +internal fun parseSlashCommands(data: JsonObject?): List { + val commands = runCatching { data?.get("commands")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + + return commands.mapNotNull { commandElement -> + val commandObject = commandElement.jsonObject + val name = commandObject.stringField("name") ?: return@mapNotNull null + SlashCommandInfo( + name = name, + description = commandObject.stringField("description"), + source = commandObject.stringField("source") ?: "unknown", + location = commandObject.stringField("location"), + path = commandObject.stringField("path"), + ) + } +} + +private fun JsonObject?.longField(fieldName: String): Long? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toLongOrNull() +} + +private fun JsonObject?.intField(fieldName: String): Int? { + val value = this?.get(fieldName)?.jsonPrimitive?.contentOrNull ?: return null + return value.toIntOrNull() +} diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt index 346654e..44448f6 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt @@ -26,9 +26,13 @@ interface SessionController { * ChatViewModel observes this to reload the timeline. */ val sessionChanged: SharedFlow + val timelineInvalidated: SharedFlow + val syncMetrics: StateFlow fun setTransportPreference(preference: TransportPreference) + fun recordSafetyPoll() + fun getTransportPreference(): TransportPreference fun getEffectiveTransportPreference(): TransportPreference @@ -139,6 +143,12 @@ interface SessionController { /** * Information about a forkable message from get_fork_messages response. */ +data class SessionSyncMetrics( + val fullRebuilds: Int = 0, + val incrementalEntries: Int = 0, + val safetyPolls: Int = 0, +) + data class ForkableMessage( val entryId: String, val preview: String, diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionCwdPreferenceStore.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionCwdPreferenceStore.kt index ff34ab7..03ef02e 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionCwdPreferenceStore.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionCwdPreferenceStore.kt @@ -2,6 +2,7 @@ package com.ayagmar.pimobile.sessions import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit interface SessionCwdPreferenceStore { fun getPreferredCwd(hostId: String): String? @@ -56,11 +57,11 @@ class SharedPreferencesSessionCwdPreferenceStore( hostId: String, cwd: String, ) { - preferences.edit().putString(cwdKey(hostId), cwd).apply() + preferences.edit { putString(cwdKey(hostId), cwd) } } override fun clearPreferredCwd(hostId: String) { - preferences.edit().remove(cwdKey(hostId)).apply() + preferences.edit { remove(cwdKey(hostId)) } } private fun cwdKey(hostId: String): String = "cwd_$hostId" diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionEntryProjection.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionEntryProjection.kt new file mode 100644 index 0000000..1bc84b5 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionEntryProjection.kt @@ -0,0 +1,184 @@ +package com.ayagmar.pimobile.sessions + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +internal class SessionEntryProjection { + private val entriesById = linkedMapOf() + private var leafId: String? = null + + fun reset() { + entriesById.clear() + leafId = null + } + + fun apply( + data: JsonObject?, + fullRebuild: Boolean, + ): ProjectionUpdate { + val incoming = parseEntries(data) + val nextLeafId = data.stringField("leafId") + if (incoming == null) { + return ProjectionUpdate.RebuildRequired + } + + if (fullRebuild) entriesById.clear() + incoming.forEach { entry -> entriesById[entry.getValue("id").jsonPrimitive.content] = entry } + val branchContinues = fullRebuild || leafId == null || isAncestor(leafId, nextLeafId) + + val update = + if (branchContinues && hasValidPath(nextLeafId)) { + leafId = nextLeafId + ProjectionUpdate.Applied(buildMessages()) + } else { + ProjectionUpdate.RebuildRequired + } + return update + } + + private fun parseEntries(data: JsonObject?): List? { + val incoming = runCatching { data?.get("entries")?.jsonArray }.getOrNull() + return incoming?.mapNotNull { element -> + val entry = runCatching { element.jsonObject }.getOrNull() + val type = entry.stringField("type") + val id = entry.stringField("id") + if (entry != null && id != null && type in SUPPORTED_ENTRY_TYPES) entry else null + }?.takeIf { entries -> entries.size == incoming.size } + } + + private fun isAncestor( + ancestorId: String?, + descendantId: String?, + ): Boolean { + var current = descendantId + val visited = mutableSetOf() + var found = ancestorId == null + while (!found && current != null && visited.add(current)) { + found = current == ancestorId + current = entriesById[current].stringField("parentId") + } + return found + } + + private fun hasValidPath(candidateLeafId: String?): Boolean { + var current = candidateLeafId + val visited = mutableSetOf() + var valid = candidateLeafId != null || entriesById.isEmpty() + while (current != null && visited.add(current)) { + val entry = entriesById[current] + valid = entry != null + current = entry.stringField("parentId") + } + return valid && current == null + } + + private fun buildMessages(): JsonArray { + val path = activePath() + val compactionIndex = path.indexOfLast { entry -> entry.stringField("type") == "compaction" } + val contextEntries = + if (compactionIndex < 0) { + path + } else { + val compaction = path[compactionIndex] + val firstKeptId = compaction.stringField("firstKeptEntryId") + val firstKeptIndex = path.indexOfFirst { entry -> entry.stringField("id") == firstKeptId } + buildList { + add(compaction) + if (firstKeptIndex >= 0) addAll(path.subList(firstKeptIndex, compactionIndex)) + addAll(path.drop(compactionIndex + 1)) + } + } + + return buildJsonArray { + contextEntries.forEach { entry -> + entry.toAgentMessage()?.let(::add) + } + } + } + + private fun activePath(): List { + val reversed = mutableListOf() + var current = leafId + while (current != null) { + val entry = entriesById.getValue(current) + reversed += entry + current = entry.stringField("parentId") + } + return reversed.asReversed() + } + + private fun JsonObject.toAgentMessage(): JsonObject? { + return when (stringField("type")) { + "message" -> runCatching { get("message")?.jsonObject }.getOrNull() + "compaction" -> + buildJsonObject { + put("role", "compactionSummary") + put("summary", stringField("summary").orEmpty()) + put("tokensBefore", longField("tokensBefore") ?: 0L) + put("timestamp", timestampMillis()) + } + "branch_summary" -> + buildJsonObject { + put("role", "branchSummary") + put("summary", stringField("summary").orEmpty()) + put("fromId", stringField("fromId").orEmpty()) + put("timestamp", timestampMillis()) + } + "custom_message" -> + buildJsonObject { + put("role", "custom") + put("customType", stringField("customType").orEmpty()) + this@toAgentMessage["content"]?.let { content -> put("content", content) } + put("display", booleanField("display") ?: false) + put("timestamp", timestampMillis()) + } + else -> null + } + } + + private fun JsonObject.timestampMillis(): Long { + val timestamp = stringField("timestamp") ?: return 0L + return runCatching { java.time.Instant.parse(timestamp).toEpochMilli() }.getOrDefault(0L) + } + + private companion object { + val SUPPORTED_ENTRY_TYPES = + setOf( + "message", + "compaction", + "branch_summary", + "custom_message", + "custom", + "model_change", + "thinking_level_change", + "label", + "session_info", + ) + } +} + +internal sealed interface ProjectionUpdate { + data class Applied(val messages: JsonArray) : ProjectionUpdate + + data object RebuildRequired : ProjectionUpdate +} + +private fun JsonObject?.stringField(name: String): String? { + return runCatching { this?.get(name)?.jsonPrimitive?.contentOrNull }.getOrNull() +} + +private fun JsonObject.longField(name: String): Long? { + return runCatching { get(name)?.jsonPrimitive?.longOrNull }.getOrNull() +} + +private fun JsonObject.booleanField(name: String): Boolean? { + return runCatching { get(name)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull() +} diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionTreeMapper.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionTreeMapper.kt new file mode 100644 index 0000000..d3568b5 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionTreeMapper.kt @@ -0,0 +1,109 @@ +package com.ayagmar.pimobile.sessions + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal fun parseRpcSessionTreeSnapshot( + data: JsonObject?, + sessionPath: String, + filter: String?, +): SessionTreeSnapshot { + val entries = mutableListOf() + val roots = runCatching { data?.get("tree")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + roots.forEach { root -> flattenRpcTreeNode(root.jsonObject, entries) } + + val visibleEntries = filterSessionTreeEntries(entries, filter ?: "default") + val visibleIds = visibleEntries.mapTo(mutableSetOf()) { entry -> entry.entryId } + val parents = entries.associate { entry -> entry.entryId to entry.parentId } + + return SessionTreeSnapshot( + sessionPath = sessionPath, + rootIds = + visibleEntries + .filter { entry -> entry.parentId == null || entry.parentId !in visibleIds } + .map { entry -> entry.entryId }, + currentLeafId = resolveVisibleTreeLeaf(data.stringField("leafId"), visibleIds, parents), + entries = visibleEntries, + ) +} + +private fun flattenRpcTreeNode( + node: JsonObject, + destination: MutableList, +) { + val entry = runCatching { node["entry"]?.jsonObject }.getOrNull() ?: error("Tree node missing entry") + val entryId = entry.stringField("id") ?: error("Tree entry missing id") + val entryType = entry.stringField("type") ?: error("Tree entry missing type") + val message = runCatching { entry["message"]?.jsonObject }.getOrNull() + val label = node.stringField("label") + + destination += + SessionTreeEntry( + entryId = entryId, + parentId = entry.stringField("parentId"), + entryType = entryType, + role = if (entryType == "custom_message") "custom" else message.stringField("role"), + timestamp = entry.stringField("timestamp"), + preview = rpcTreeEntryPreview(entry, message), + label = label, + isBookmarked = label != null, + ) + + val children = runCatching { node["children"]?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) + children.forEach { child -> flattenRpcTreeNode(child.jsonObject, destination) } +} + +private fun rpcTreeEntryPreview( + entry: JsonObject, + message: JsonObject?, +): String { + val content = message?.get("content") + val text = + when (content) { + is JsonPrimitive -> content.contentOrNull + is JsonArray -> + content.firstNotNullOfOrNull { block -> + runCatching { block.jsonObject.stringField("text") }.getOrNull() + } + else -> null + } + + return text ?: entry.stringField("summary") ?: entry.stringField("name") ?: entry.stringField("type") ?: "entry" +} + +private fun filterSessionTreeEntries( + entries: List, + filter: String, +): List { + return when (filter) { + "default" -> entries.filter { entry -> entry.entryType != "label" && entry.entryType != "custom" } + "all" -> entries + "no-tools" -> entries.filter { entry -> entry.role != "toolResult" } + "user-only" -> entries.filter { entry -> entry.role == "user" } + "labeled-only" -> entries.filter { entry -> entry.isBookmarked || entry.entryType == "label" } + else -> error("Unsupported tree filter: $filter") + } +} + +private fun resolveVisibleTreeLeaf( + leafId: String?, + visibleIds: Set, + parents: Map, +): String? { + var current = leafId + val visited = mutableSetOf() + while (current != null && visited.add(current)) { + if (current in visibleIds) return current + current = parents[current] + } + return null +} + +private fun JsonObject?.stringField(fieldName: String): String? { + return runCatching { this?.get(fieldName)?.jsonPrimitive?.contentOrNull }.getOrNull() +} diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SharedPreferencesClientIdentityStore.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SharedPreferencesClientIdentityStore.kt index d1092bf..e8acc08 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SharedPreferencesClientIdentityStore.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SharedPreferencesClientIdentityStore.kt @@ -3,6 +3,7 @@ package com.ayagmar.pimobile.sessions import android.annotation.SuppressLint import android.content.Context import android.provider.Settings +import androidx.core.content.edit import java.util.UUID class SharedPreferencesClientIdentityStore( @@ -33,7 +34,7 @@ class SharedPreferencesClientIdentityStore( UUID.randomUUID().toString() } - prefs.edit().putString("client_id", finalId).apply() + prefs.edit { putString("client_id", finalId) } return finalId } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 409c23e..b183749 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -10,17 +10,16 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.filled.Computer import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.MenuOpen import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.DrawerValue @@ -84,7 +83,7 @@ private val destinations = AppDestination( route = "chat", label = "Chat", - icon = Icons.Default.Chat, + icon = Icons.AutoMirrored.Filled.Chat, ), AppDestination( route = "settings", @@ -177,7 +176,7 @@ private fun DrawerDestinationItem( @Suppress("LongMethod") @Composable -fun piMobileApp(appGraph: AppGraph) { +fun PiMobileApp(appGraph: AppGraph) { val context = LocalContext.current val settingsPrefs = remember(context) { @@ -230,19 +229,13 @@ fun piMobileApp(appGraph: AppGraph) { } } - val startDestination = - remember(appGraph) { - if (appGraph.hostProfileStore.list().isEmpty()) { - "hosts" - } else { - "sessions" - } - } + val hasConfiguredHost = remember(appGraph) { appGraph.hostProfileStore.list().isNotEmpty() } + val startDestination = if (hasConfiguredHost) "sessions" else "hosts" + val availableDestinations = destinations.filter { destination -> destination.route != "chat" } ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, - scrimColor = Color.Transparent, drawerContent = { ModalDrawerSheet( modifier = Modifier.widthIn(min = 220.dp, max = 270.dp), @@ -281,7 +274,7 @@ fun piMobileApp(appGraph: AppGraph) { modifier = Modifier.padding(horizontal = 8.dp), ) - destinations.forEach { destination -> + availableDestinations.forEach { destination -> DrawerDestinationItem( destination = destination, selected = currentRoute == destination.route, @@ -302,13 +295,16 @@ fun piMobileApp(appGraph: AppGraph) { NavHost( navController = navController, startDestination = startDestination, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(top = 56.dp), ) { composable(route = "hosts") { HostsRoute( profileStore = appGraph.hostProfileStore, tokenStore = appGraph.hostTokenStore, diagnostics = appGraph.connectionDiagnostics, + onHostSaved = { + navigateTo("sessions") + }, ) } composable(route = "sessions") { @@ -341,11 +337,11 @@ fun piMobileApp(appGraph: AppGraph) { color = MaterialTheme.colorScheme.surface.copy(alpha = 0.86f), modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp), + .align(Alignment.TopStart) + .padding(8.dp), ) { FilledTonalIconButton( - modifier = Modifier.size(34.dp), + modifier = Modifier.size(48.dp), onClick = { scope.launch { if (drawerState.isOpen) { @@ -357,7 +353,12 @@ fun piMobileApp(appGraph: AppGraph) { }, ) { Icon( - imageVector = if (drawerState.isOpen) Icons.Default.MenuOpen else Icons.Default.Menu, + imageVector = + if (drawerState.isOpen) { + Icons.AutoMirrored.Filled.MenuOpen + } else { + Icons.Default.Menu + }, contentDescription = if (drawerState.isOpen) { "Close left navigation" diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/AssistantMessageBlock.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/AssistantMessageBlock.kt new file mode 100644 index 0000000..3bb4bef --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/AssistantMessageBlock.kt @@ -0,0 +1,101 @@ +package com.ayagmar.pimobile.ui.chat + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString + +internal sealed interface AssistantMessageBlock { + data class Paragraph( + val text: String, + ) : AssistantMessageBlock + + data class Code( + val code: String, + val language: String?, + ) : AssistantMessageBlock +} + +internal fun parseAssistantMessageBlocks(text: String): List { + if (text.isBlank()) return emptyList() + + val blocks = mutableListOf() + var cursor = 0 + + CODE_FENCE_REGEX.findAll(text).forEach { match -> + val matchStart = match.range.first + val matchEndExclusive = match.range.last + 1 + + if (matchStart > cursor) { + val paragraph = text.substring(cursor, matchStart).trim() + if (paragraph.isNotEmpty()) { + blocks += AssistantMessageBlock.Paragraph(paragraph) + } + } + + val language = match.groupValues[1].takeIf { it.isNotBlank() } + val code = match.groupValues[2] + blocks += AssistantMessageBlock.Code(code = code.trimEnd(), language = language) + cursor = matchEndExclusive + } + + if (cursor < text.length) { + val paragraph = text.substring(cursor).trim() + if (paragraph.isNotEmpty()) { + blocks += AssistantMessageBlock.Paragraph(paragraph) + } + } + + return blocks +} + +internal fun highlightCodeBlock( + code: String, + language: String?, + colors: androidx.compose.material3.ColorScheme, +): AnnotatedString { + val text = code.ifBlank { "(empty code block)" } + val commentPattern = commentRegexFor(language) + val keywordPattern = keywordRegexFor(language) + + val commentStyle = SpanStyle(color = colors.outline) + val stringStyle = SpanStyle(color = colors.tertiary) + val numberStyle = SpanStyle(color = colors.secondary) + val keywordStyle = SpanStyle(color = colors.primary) + + return buildAnnotatedString { + append(text) + + applyStyle(STRING_REGEX, stringStyle, text) + applyStyle(NUMBER_REGEX, numberStyle, text) + applyStyle(keywordPattern, keywordStyle, text) + applyStyle(commentPattern, commentStyle, text) + } +} + +private fun AnnotatedString.Builder.applyStyle( + regex: Regex, + style: SpanStyle, + text: String, +) { + regex.findAll(text).forEach { match -> + addStyle(style, match.range.first, match.range.last + 1) + } +} + +private fun keywordRegexFor(language: String?): Regex { + return when (language?.lowercase()) { + "kotlin", "kt" -> KOTLIN_KEYWORD_REGEX + "java" -> JAVA_KEYWORD_REGEX + "python", "py" -> PYTHON_KEYWORD_REGEX + "javascript", "js", "typescript", "ts", "tsx" -> JS_TS_KEYWORD_REGEX + "bash", "shell", "sh" -> BASH_KEYWORD_REGEX + else -> GENERIC_KEYWORD_REGEX + } +} + +private fun commentRegexFor(language: String?): Regex { + return when (language?.lowercase()) { + "python", "py", "bash", "shell", "sh", "yaml", "yml" -> HASH_COMMENT_REGEX + else -> SLASH_COMMENT_REGEX + } +} diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatAuxiliarySheets.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatAuxiliarySheets.kt new file mode 100644 index 0000000..165b6e5 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatAuxiliarySheets.kt @@ -0,0 +1,738 @@ +package com.ayagmar.pimobile.ui.chat + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.ayagmar.pimobile.chat.ChatViewModel +import com.ayagmar.pimobile.corerpc.AvailableModel +import com.ayagmar.pimobile.corerpc.SessionStats +import com.ayagmar.pimobile.sessions.ModelInfo +import com.ayagmar.pimobile.sessions.SessionTreeEntry +import com.ayagmar.pimobile.sessions.SessionTreeSnapshot + +@Suppress("LongParameterList", "LongMethod") +@Composable +internal fun SessionStatsSheet( + isVisible: Boolean, + stats: SessionStats?, + sessionName: String?, + pendingMessageCount: Int, + isLoading: Boolean, + onRefresh: () -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val clipboardManager = LocalClipboardManager.current + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Session Statistics") + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh", + ) + } + } + }, + text = { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (stats == null) { + Text( + text = "No statistics available", + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (sessionName != null || pendingMessageCount > 0) { + StatsSection(title = "Session") { + sessionName?.let { StatRow("Name", it) } + if (pendingMessageCount > 0) { + StatRow("Queued Messages", pendingMessageCount.toString()) + } + } + } + + // Token stats + StatsSection(title = "Tokens") { + StatRow("Input Tokens", formatNumber(stats.inputTokens)) + StatRow("Output Tokens", formatNumber(stats.outputTokens)) + StatRow("Cache Read", formatNumber(stats.cacheReadTokens)) + StatRow("Cache Write", formatNumber(stats.cacheWriteTokens)) + } + + // Cost + StatsSection(title = "Cost") { + StatRow("Total Cost", formatCost(stats.totalCost)) + } + + // Messages + StatsSection(title = "Messages") { + StatRow("Total", stats.messageCount.toString()) + StatRow("User", stats.userMessageCount.toString()) + StatRow("Assistant", stats.assistantMessageCount.toString()) + StatRow("Tool Results", stats.toolResultCount.toString()) + } + + // Session path + stats.sessionPath?.let { path -> + StatsSection(title = "Session File") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = path.takeLast(SESSION_PATH_DISPLAY_LENGTH), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { clipboardManager.setText(AnnotatedString(path)) }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy path", + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + ) +} + +@Composable +private fun StatsSection( + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp), + ) + content() + } +} + +@Composable +private fun StatRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } +} + +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun ModelPickerSheet( + isVisible: Boolean, + models: List, + currentModel: ModelInfo?, + query: String, + isLoading: Boolean, + onQueryChange: (String) -> Unit, + onSelectModel: (AvailableModel) -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val filteredModels = + remember(models, query) { + if (query.isBlank()) { + models + } else { + models.filter { model -> + model.name.contains(query, ignoreCase = true) || + model.provider.contains(query, ignoreCase = true) || + model.id.contains(query, ignoreCase = true) + } + } + } + + val groupedModels = + remember(filteredModels) { + filteredModels.groupBy { it.provider } + } + val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val selectedModelIndex = + remember(groupedModels, currentModel) { + if (currentModel == null) { + -1 + } else { + var index = 0 + var foundIndex = -1 + groupedModels.forEach { (_, modelsInGroup) -> + index += 1 // provider header item + modelsInGroup.forEach { model -> + if ( + foundIndex < 0 && + model.id == currentModel.id && + model.provider == currentModel.provider + ) { + foundIndex = index + } + index += 1 + } + } + foundIndex + } + } + + LaunchedEffect(selectedModelIndex, isVisible) { + if (isVisible && selectedModelIndex >= 0) { + listState.scrollToItem((selectedModelIndex - MODEL_PICKER_SCROLL_OFFSET_ITEMS).coerceAtLeast(0)) + } + } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Select Model") }, + text = { + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = 500.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search models...") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + ) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (filteredModels.isEmpty()) { + Text( + text = "No models found", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + ) { + groupedModels.forEach { (provider, modelsInGroup) -> + item { + Text( + text = provider.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items( + items = modelsInGroup, + key = { model -> "${model.provider}:${model.id}" }, + ) { model -> + ModelItem( + model = model, + isSelected = + currentModel?.id == model.id && + currentModel.provider == model.provider, + onClick = { onSelectModel(model) }, + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Suppress("LongMethod") +@Composable +private fun ModelItem( + model: AvailableModel, + isSelected: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() }, + colors = + if (isSelected) { + androidx.compose.material3.CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + androidx.compose.material3.CardDefaults.cardColors() + }, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = model.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (model.supportsThinking) { + AssistChip( + onClick = {}, + label = { + Text( + "Thinking", + style = MaterialTheme.typography.labelSmall, + ) + }, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + model.contextWindow?.let { ctx -> + Text( + text = "Context: ${formatNumber(ctx.toLong())}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + model.inputCostPer1k?.let { cost -> + Text( + text = "In: \$${String.format(java.util.Locale.US, "%.4f", cost)}/1k", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + model.outputCostPer1k?.let { cost -> + Text( + text = "Out: \$${String.format(java.util.Locale.US, "%.4f", cost)}/1k", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Suppress("LongParameterList", "LongMethod") +@Composable +internal fun TreeNavigationSheet( + isVisible: Boolean, + tree: SessionTreeSnapshot?, + selectedFilter: String, + isLoading: Boolean, + errorMessage: String?, + onFilterChange: (String) -> Unit, + onForkFromEntry: (String) -> Unit, + onJumpAndContinue: (String) -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val entries = tree?.entries.orEmpty() + val depthByEntry = remember(entries) { computeDepthMap(entries) } + val childCountByEntry = remember(entries) { computeChildCountMap(entries) } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Session tree") }, + text = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp)) { + tree?.sessionPath?.let { sessionPath -> + Text( + text = truncatePath(sessionPath), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + // Scrollable filter chips to avoid overflow + LazyRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items( + items = TREE_FILTER_OPTIONS, + key = { (filter, _) -> filter }, + ) { (filter, label) -> + FilterChip( + selected = filter == selectedFilter, + onClick = { onFilterChange(filter) }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + errorMessage != null -> { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + entries.isEmpty() -> { + Text( + text = "No tree data available", + style = MaterialTheme.typography.bodyMedium, + ) + } + + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items( + items = entries, + key = { entry -> entry.entryId }, + ) { entry -> + TreeEntryRow( + entry = entry, + depth = depthByEntry[entry.entryId] ?: 0, + childCount = childCountByEntry[entry.entryId] ?: 0, + isCurrent = tree?.currentLeafId == entry.entryId, + onForkFromEntry = onForkFromEntry, + onJumpAndContinue = onJumpAndContinue, + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + ) +} + +@Suppress("MagicNumber", "LongMethod", "LongParameterList") +@Composable +private fun TreeEntryRow( + entry: SessionTreeEntry, + depth: Int, + childCount: Int, + isCurrent: Boolean, + onForkFromEntry: (String) -> Unit, + onJumpAndContinue: (String) -> Unit, +) { + val indent = (depth * 8).dp + val isMessage = entry.entryType == "message" + val containerColor = + when { + isCurrent -> MaterialTheme.colorScheme.primaryContainer + isMessage -> MaterialTheme.colorScheme.surface + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + val contentColor = + when { + isCurrent -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + + Card( + modifier = Modifier.fillMaxWidth().padding(start = indent), + colors = + CardDefaults.cardColors( + containerColor = containerColor, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val typeIcon = treeEntryIcon(entry.entryType) + Icon( + imageVector = typeIcon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = contentColor.copy(alpha = 0.7f), + ) + val label = + buildString { + append(entry.entryType.replace('_', ' ')) + entry.role?.let { append(" · $it") } + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f), + ) + } + + if (isCurrent) { + Text( + text = "● current", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isMessage) { + Text( + text = entry.preview, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + color = contentColor, + ) + } + + if (entry.isBookmarked && !entry.label.isNullOrBlank()) { + Text( + text = "🔖 ${entry.label}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (childCount > 1) { + Text( + text = "↳ $childCount branches", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { + TextButton( + onClick = { onJumpAndContinue(entry.entryId) }, + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 8.dp, + vertical = 0.dp, + ), + ) { + Text("Jump", style = MaterialTheme.typography.labelSmall) + } + TextButton( + onClick = { onForkFromEntry(entry.entryId) }, + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 8.dp, + vertical = 0.dp, + ), + ) { + Text("Fork", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } +} + +private fun treeEntryIcon(entryType: String): ImageVector { + return when (entryType) { + "message" -> Icons.Default.Description + "model_change" -> Icons.Default.Refresh + "thinking_level_change" -> Icons.Default.Menu + else -> Icons.Default.PlayArrow + } +} + +@Suppress("ReturnCount") +private fun computeDepthMap(entries: List): Map { + val byId = entries.associateBy { it.entryId } + val memo = mutableMapOf() + + fun depth( + entryId: String, + stack: MutableSet, + ): Int { + memo[entryId]?.let { return it } + if (!stack.add(entryId)) { + return 0 + } + + val entry = byId[entryId] + val resolvedDepth = + when { + entry == null -> 0 + entry.parentId == null -> 0 + else -> depth(entry.parentId, stack) + 1 + } + + stack.remove(entryId) + memo[entryId] = resolvedDepth + return resolvedDepth + } + + entries.forEach { entry -> depth(entry.entryId, mutableSetOf()) } + return memo +} + +private fun computeChildCountMap(entries: List): Map { + return entries + .groupingBy { it.parentId } + .eachCount() + .mapNotNull { (parentId, count) -> + parentId?.let { it to count } + }.toMap() +} + +private val TREE_FILTER_OPTIONS = + listOf( + ChatViewModel.TREE_FILTER_DEFAULT to "default", + ChatViewModel.TREE_FILTER_ALL to "all", + ChatViewModel.TREE_FILTER_NO_TOOLS to "no-tools", + ChatViewModel.TREE_FILTER_USER_ONLY to "user-only", + ChatViewModel.TREE_FILTER_LABELED_ONLY to "labeled-only", + ) + +private const val MODEL_PICKER_SCROLL_OFFSET_ITEMS = 1 +private const val SESSION_PATH_DISPLAY_LENGTH = 40 + +private fun truncatePath(path: String): String { + if (path.length <= SESSION_PATH_DISPLAY_LENGTH) { + return path + } + val head = SESSION_PATH_DISPLAY_LENGTH / 2 + val tail = SESSION_PATH_DISPLAY_LENGTH - head - 1 + return "${path.take(head)}…${path.takeLast(tail)}" +} diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatComposer.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatComposer.kt new file mode 100644 index 0000000..1c3932b --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatComposer.kt @@ -0,0 +1,598 @@ +package com.ayagmar.pimobile.ui.chat + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +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.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.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import coil.compose.AsyncImage +import com.ayagmar.pimobile.chat.ChatViewModel +import com.ayagmar.pimobile.chat.ImageEncoder +import com.ayagmar.pimobile.chat.PendingImage +import com.ayagmar.pimobile.chat.PendingQueueItem +import com.ayagmar.pimobile.chat.PendingQueueType +import kotlinx.coroutines.launch + +@Suppress("LongMethod", "LongParameterList") +@Composable +internal fun PromptControls( + isStreaming: Boolean, + isRetrying: Boolean, + pendingQueueItems: List, + steeringMode: String, + followUpMode: String, + inputText: String, + pendingImages: List, + callbacks: PromptControlsCallbacks, +) { + var showSteerDialog by remember { mutableStateOf(false) } + var showFollowUpDialog by remember { mutableStateOf(false) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .testTag(CHAT_PROMPT_CONTROLS_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = isStreaming || isRetrying, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + StreamingControls( + isRetrying = isRetrying, + onAbort = callbacks.onAbort, + onAbortRetry = callbacks.onAbortRetry, + onSteerClick = { showSteerDialog = true }, + onFollowUpClick = { showFollowUpDialog = true }, + ) + } + + AnimatedVisibility( + visible = isStreaming && pendingQueueItems.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PendingQueueInspector( + pendingItems = pendingQueueItems, + steeringMode = steeringMode, + followUpMode = followUpMode, + onRemoveItem = callbacks.onRemovePendingQueueItem, + onClear = callbacks.onClearPendingQueueItems, + ) + } + + PromptInputRow( + inputText = inputText, + isStreaming = isStreaming, + pendingImages = pendingImages, + onInputTextChanged = callbacks.onInputTextChanged, + onSendPrompt = callbacks.onSendPrompt, + onShowCommandPalette = callbacks.onShowCommandPalette, + onAddImage = callbacks.onAddImage, + onRemoveImage = callbacks.onRemoveImage, + ) + } + + if (showSteerDialog) { + SteerFollowUpDialog( + title = "Steer", + onDismiss = { showSteerDialog = false }, + onConfirm = { message -> + callbacks.onSteer(message) + showSteerDialog = false + }, + ) + } + + if (showFollowUpDialog) { + SteerFollowUpDialog( + title = "Follow Up", + onDismiss = { showFollowUpDialog = false }, + onConfirm = { message -> + callbacks.onFollowUp(message) + showFollowUpDialog = false + }, + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun StreamingControls( + isRetrying: Boolean, + onAbort: () -> Unit, + onAbortRetry: () -> Unit, + onSteerClick: () -> Unit, + onFollowUpClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().testTag(CHAT_STREAMING_CONTROLS_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onAbort, + modifier = Modifier.weight(1f), + colors = + androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + text = "Abort", + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + + if (isRetrying) { + OutlinedButton( + onClick = onAbortRetry, + modifier = Modifier.weight(1f), + ) { + Text(text = "Abort Retry", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } + } + } + + if (!isRetrying) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onSteerClick, + modifier = Modifier.weight(1f), + ) { + Text(text = "Steer", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } + + OutlinedButton( + onClick = onFollowUpClick, + modifier = Modifier.weight(1f), + ) { + Text(text = "Follow Up", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun PendingQueueInspector( + pendingItems: List, + steeringMode: String, + followUpMode: String, + onRemoveItem: (String) -> Unit, + onClear: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Pending queue (${pendingItems.size})", + style = MaterialTheme.typography.bodyMedium, + ) + TextButton(onClick = onClear) { + Text("Clear") + } + } + + Text( + text = "Steer: ${deliveryModeLabel(steeringMode)} · Follow-up: ${deliveryModeLabel(followUpMode)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + pendingItems.forEach { item -> + PendingQueueItemRow( + item = item, + onRemove = { onRemoveItem(item.id) }, + ) + } + + Text( + text = "Items shown here were sent while streaming; clearing only removes local inspector entries.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PendingQueueItemRow( + item: PendingQueueItem, + onRemove: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + val typeLabel = + when (item.type) { + PendingQueueType.STEER -> "Steer" + PendingQueueType.FOLLOW_UP -> "Follow-up" + } + Text( + text = "$typeLabel · ${deliveryModeLabel(item.mode)}", + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = item.message, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + ) + } + + TextButton(onClick = onRemove) { + Text("Remove") + } + } +} + +private fun deliveryModeLabel(mode: String): String { + return when (mode) { + ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME -> "one-at-a-time" + else -> "all" + } +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +internal fun PromptInputRow( + inputText: String, + isStreaming: Boolean, + pendingImages: List, + onInputTextChanged: (String) -> Unit, + onSendPrompt: () -> Unit, + onShowCommandPalette: () -> Unit = {}, + onAddImage: (PendingImage) -> Unit, + onRemoveImage: (Int) -> Unit, +) { + val context = LocalContext.current + val imageEncoder = remember { ImageEncoder(context) } + var previewImageUri by rememberSaveable { mutableStateOf(null) } + + val submitPrompt = { + onSendPrompt() + } + + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(), + ) { uris -> + uris.forEach { uri -> + imageEncoder.getImageInfo(uri)?.let { info -> onAddImage(info) } + } + } + + Column(modifier = Modifier.fillMaxWidth().testTag(CHAT_PROMPT_INPUT_ROW_TAG)) { + // Pending images strip + if (pendingImages.isNotEmpty()) { + ImageAttachmentStrip( + images = pendingImages, + onRemove = onRemoveImage, + onImageClick = { uri -> + previewImageUri = uri + }, + ) + } + + OutlinedTextField( + value = inputText, + onValueChange = onInputTextChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Message Pi") }, + singleLine = false, + minLines = 1, + maxLines = 5, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), + enabled = !isStreaming, + leadingIcon = { + IconButton( + onClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + enabled = !isStreaming, + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "Attach image", + ) + } + }, + trailingIcon = { + val canSend = inputText.isNotBlank() || pendingImages.isNotEmpty() + if (!canSend && !isStreaming) { + IconButton(onClick = onShowCommandPalette) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Commands", + ) + } + } else { + IconButton( + onClick = submitPrompt, + enabled = canSend && !isStreaming, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + ) + } + } + }, + ) + + previewImageUri?.let { uri -> + ImagePreviewDialog( + uriString = uri, + onDismiss = { previewImageUri = null }, + ) + } + } +} + +@Composable +private fun ImageAttachmentStrip( + images: List, + onRemove: (Int) -> Unit, + onImageClick: (String) -> Unit, +) { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = images, + key = { index, image -> "${image.uri}-$index" }, + ) { index, image -> + ImageThumbnail( + image = image, + onRemove = { onRemove(index) }, + onClick = { onImageClick(image.uri) }, + ) + } + } +} + +@Suppress("MagicNumber", "LongMethod") +@Composable +private fun ImageThumbnail( + image: PendingImage, + onRemove: () -> Unit, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + val uri = remember(image.uri) { image.uri.toUri() } + AsyncImage( + model = uri, + contentDescription = image.displayName, + modifier = Modifier.fillMaxSize().clickable(onClick = onClick), + contentScale = ContentScale.Crop, + ) + + // Size warning badge + if (image.sizeBytes > ImageEncoder.MAX_IMAGE_SIZE_BYTES) { + Box( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(2.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.error) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Text( + text = ">5MB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onError, + ) + } + } + + // Remove button + IconButton( + onClick = onRemove, + modifier = + Modifier + .align(Alignment.TopEnd) + .size(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(14.dp), + ) + } + + // File name / size label + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .padding(2.dp), + ) { + Text( + text = formatFileSize(image.sizeBytes), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +@Suppress("MagicNumber") +private fun formatFileSize(bytes: Long): String { + return when { + bytes >= 1_048_576 -> String.format(java.util.Locale.US, "%.1fMB", bytes / 1_048_576.0) + bytes >= 1_024 -> String.format(java.util.Locale.US, "%.0fKB", bytes / 1_024.0) + else -> "${bytes}B" + } +} + +@Composable +internal fun ImagePreviewDialog( + uriString: String, + onDismiss: () -> Unit, +) { + val uri = remember(uriString) { uriString.toUri() } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = uri, + contentDescription = "Image preview", + modifier = Modifier.fillMaxSize().padding(16.dp), + contentScale = ContentScale.Fit, + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close image preview", + tint = Color.White, + ) + } + } + } +} + +@Composable +private fun SteerFollowUpDialog( + title: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var text by rememberSaveable { mutableStateOf("") } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text("Enter your message...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 6, + ) + }, + confirmButton = { + Button( + onClick = { onConfirm(text) }, + enabled = text.isNotBlank(), + ) { + Text("Send") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 92dd6a1..8909356 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -4,54 +4,26 @@ import android.net.Uri import android.provider.OpenableColumns import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -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.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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -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.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.AssistChip @@ -61,64 +33,46 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage import com.ayagmar.pimobile.chat.ChatTimelineItem import com.ayagmar.pimobile.chat.ChatUiState import com.ayagmar.pimobile.chat.ChatViewModel import com.ayagmar.pimobile.chat.ChatViewModelFactory -import com.ayagmar.pimobile.chat.ExtensionWidget import com.ayagmar.pimobile.chat.ImageEncoder import com.ayagmar.pimobile.chat.PendingImage -import com.ayagmar.pimobile.chat.PendingQueueItem -import com.ayagmar.pimobile.chat.PendingQueueType import com.ayagmar.pimobile.corerpc.AvailableModel -import com.ayagmar.pimobile.corerpc.SessionStats import com.ayagmar.pimobile.perf.StreamingFrameMetrics import com.ayagmar.pimobile.sessions.ModelInfo import com.ayagmar.pimobile.sessions.SessionController -import com.ayagmar.pimobile.sessions.SessionTreeEntry -import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo -import dev.jeziellago.compose.markdowntext.MarkdownText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -155,7 +109,7 @@ private fun resolveDocumentDisplayName( }.getOrNull() } -private data class ChatCallbacks( +internal data class ChatCallbacks( val onToggleToolExpansion: (String) -> Unit, val onToggleThinkingExpansion: (String) -> Unit, val onToggleDiffExpansion: (String) -> Unit, @@ -239,7 +193,28 @@ fun ChatRoute( ) } val chatViewModel: ChatViewModel = viewModel(factory = factory) + val lifecycleOwner = LocalLifecycleOwner.current val uiState by chatViewModel.uiState.collectAsStateWithLifecycle() + + DisposableEffect( + lifecycleOwner, + chatViewModel, + ) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> chatViewModel.onChatActiveChanged(true) + Lifecycle.Event.ON_STOP -> chatViewModel.onChatActiveChanged(false) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + chatViewModel.onChatActiveChanged(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + chatViewModel.onChatActiveChanged(false) + } + } val importSessionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -492,8 +467,12 @@ private fun ChatScreenContent( } Column( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp).imePadding(), - verticalArrangement = Arrangement.spacedBy(if (isRunActive) 8.dp else 12.dp), + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { ChatHeader( isRunActive = isRunActive, @@ -536,6 +515,10 @@ private fun ChatScreenContent( placement = "belowEditor", ) + if (showExtensionStatusStrip) { + ExtensionStatusStrip(statuses = state.extensionStatuses) + } + PromptControls( isStreaming = isRunActive, isRetrying = state.isRetrying, @@ -559,10 +542,6 @@ private fun ChatScreenContent( onClearPendingQueueItems = callbacks.onClearPendingQueueItems, ), ) - - if (showExtensionStatusStrip) { - ExtensionStatusStrip(statuses = state.extensionStatuses) - } } } @@ -702,35 +681,12 @@ private fun ChatHeader( ModelThinkingControls( currentModel = currentModel, thinkingLevel = thinkingLevel, + contextUsageLabel = contextUsageLabel, onSetThinkingLevel = callbacks.onSetThinkingLevel, onShowModelPicker = callbacks.onShowModelPicker, + onShowStats = callbacks.onShowStatsSheet, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AssistChip( - onClick = callbacks.onShowStatsSheet, - label = { Text(contextUsageLabel) }, - ) - if (pendingMessageCount > 0) { - AssistChip( - onClick = callbacks.onShowStatsSheet, - label = { Text(formatQueuedMessagesLabel(pendingMessageCount)) }, - ) - } - } - TextButton(onClick = callbacks.onRefreshStats) { - Text("Refresh") - } - } - // Error message if any errorMessage?.let { message -> Text( @@ -801,7 +757,7 @@ private fun LiveRunProgressIndicator( } @Composable -private fun InlineRunProgressCard( +internal fun InlineRunProgressCard( phase: LiveRunPhase, elapsedSeconds: Long, ) { @@ -830,3071 +786,218 @@ private fun InlineRunProgressCard( } } -@Suppress("LongParameterList") -@Composable -private fun ChatBody( - isLoading: Boolean, - timeline: List, - hasOlderMessages: Boolean, - hiddenHistoryCount: Int, - expandedToolArguments: Set, - isRunActive: Boolean, - runPhase: LiveRunPhase, - runElapsedSeconds: Long, - callbacks: ChatCallbacks, -) { - val hasStreamingTimelineItem = - remember(timeline) { - timeline.any { item -> - when (item) { - is ChatTimelineItem.Assistant -> item.isStreaming - is ChatTimelineItem.Tool -> item.isStreaming - is ChatTimelineItem.User -> false - } - } - } - val showInlineRunProgress = isRunActive && !hasStreamingTimelineItem - - if (isLoading) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 24.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - } - } else if (timeline.isEmpty() && !showInlineRunProgress) { - Text( - text = "No chat messages yet. Resume a session and send a prompt.", - style = MaterialTheme.typography.bodyLarge, - ) - } else { - ChatTimeline( - timeline = timeline, - hasOlderMessages = hasOlderMessages, - hiddenHistoryCount = hiddenHistoryCount, - expandedToolArguments = expandedToolArguments, - isRunActive = isRunActive, - showInlineRunProgress = showInlineRunProgress, - runPhase = runPhase, - runElapsedSeconds = runElapsedSeconds, - onLoadOlderMessages = callbacks.onLoadOlderMessages, - onToggleToolExpansion = callbacks.onToggleToolExpansion, - onToggleThinkingExpansion = callbacks.onToggleThinkingExpansion, - onToggleDiffExpansion = callbacks.onToggleDiffExpansion, - onToggleToolArgumentsExpansion = callbacks.onToggleToolArgumentsExpansion, - modifier = Modifier.fillMaxSize(), - ) - } -} - -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @Composable -private fun ChatTimeline( - timeline: List, - hasOlderMessages: Boolean, - hiddenHistoryCount: Int, - expandedToolArguments: Set, - isRunActive: Boolean, - showInlineRunProgress: Boolean, - runPhase: LiveRunPhase, - runElapsedSeconds: Long, - onLoadOlderMessages: () -> Unit, - onToggleToolExpansion: (String) -> Unit, - onToggleThinkingExpansion: (String) -> Unit, - onToggleDiffExpansion: (String) -> Unit, - onToggleToolArgumentsExpansion: (String) -> Unit, - modifier: Modifier = Modifier, +private fun BashDialog( + isVisible: Boolean, + command: String, + output: String, + exitCode: Int?, + isExecuting: Boolean, + wasTruncated: Boolean, + fullLogPath: String?, + history: List, + onCommandChange: (String) -> Unit, + onExecute: () -> Unit, + onAbort: () -> Unit, + onSelectHistory: (String) -> Unit, + onDismiss: () -> Unit, ) { - var previewImageUri by rememberSaveable { mutableStateOf(null) } - val listState = androidx.compose.foundation.lazy.rememberLazyListState() - val autoScrollUi = - rememberTimelineAutoScrollUi( - listState = listState, - timeline = timeline, - showInlineRunProgress = showInlineRunProgress, - isRunActive = isRunActive, - ) + if (!isVisible) return - Box(modifier = modifier.fillMaxWidth()) { - ChatTimelineList( - listState = listState, - timeline = timeline, - hasOlderMessages = hasOlderMessages, - hiddenHistoryCount = hiddenHistoryCount, - expandedToolArguments = expandedToolArguments, - showInlineRunProgress = showInlineRunProgress, - runPhase = runPhase, - runElapsedSeconds = runElapsedSeconds, - onLoadOlderMessages = onLoadOlderMessages, - onToggleToolExpansion = onToggleToolExpansion, - onToggleThinkingExpansion = onToggleThinkingExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, - onPreviewImage = { uri -> - previewImageUri = uri - }, - ) + var showHistoryDropdown by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current - AnimatedVisibility( - visible = autoScrollUi.shouldShowJumpToLatest, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), - ) { - OutlinedButton( - onClick = autoScrollUi.onJumpToLatest, - modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), + androidx.compose.material3.AlertDialog( + onDismissRequest = { if (!isExecuting) onDismiss() }, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Jump to latest") - } - } - - previewImageUri?.let { uri -> - ImagePreviewDialog( - uriString = uri, - onDismiss = { previewImageUri = null }, - ) - } - } -} - -private data class TimelineAutoScrollUi( - val shouldShowJumpToLatest: Boolean, - val onJumpToLatest: () -> Unit, -) - -@Suppress("LongMethod") -@Composable -private fun rememberTimelineAutoScrollUi( - listState: androidx.compose.foundation.lazy.LazyListState, - timeline: List, - showInlineRunProgress: Boolean, - isRunActive: Boolean, -): TimelineAutoScrollUi { - val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() - val contentItemsCount = timeline.size + if (showInlineRunProgress) 1 else 0 - val renderedItemsCount = contentItemsCount + 1 // includes bottom anchor item - val latestTimelineActivityKey = - remember(timeline, showInlineRunProgress) { - buildLatestTimelineActivityKey( - timeline = timeline, - showInlineRunProgress = showInlineRunProgress, - ) - } - val isNearBottom = rememberIsNearBottom(listState) - var shouldStickToBottom by - rememberShouldStickToBottom( - listState = listState, - isNearBottom = isNearBottom, - renderedItemsCount = renderedItemsCount, - ) - - val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom - - RunActivityAutoScroll( - listState = listState, - latestTimelineActivityKey = latestTimelineActivityKey, - renderedItemsCount = renderedItemsCount, - shouldAutoScrollToBottom = shouldAutoScrollToBottom, - ) - - RunStreamingAutoScroll( - listState = listState, - isRunActive = isRunActive, - shouldAutoScrollToBottom = shouldAutoScrollToBottom, - renderedItemsCount = renderedItemsCount, - ) - - return TimelineAutoScrollUi( - shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom, - onJumpToLatest = { - shouldStickToBottom = true - coroutineScope.launch { - listState.animateScrollToItem(renderedItemsCount - 1) + Text("Run Bash Command") + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) } }, - ) -} + text = { + Column( + modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp, max = 400.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Command input with history dropdown + Box { + OutlinedTextField( + value = command, + onValueChange = onCommandChange, + placeholder = { Text("Enter command...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isExecuting, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + trailingIcon = { + if (history.isNotEmpty() && !isExecuting) { + IconButton(onClick = { showHistoryDropdown = true }) { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "History", + ) + } + } + }, + ) -private fun buildLatestTimelineActivityKey( - timeline: List, - showInlineRunProgress: Boolean, -): String { - val tail = timeline.lastOrNull() - val tailKey = - when (tail) { - is ChatTimelineItem.Assistant -> { - "assistant:${tail.id}:${tail.text.length}:${tail.thinking?.length ?: 0}:${tail.isStreaming}" - } + DropdownMenu( + expanded = showHistoryDropdown, + onDismissRequest = { showHistoryDropdown = false }, + ) { + history.forEach { historyCommand -> + DropdownMenuItem( + text = { + Text( + text = historyCommand, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + ) + }, + onClick = { + onSelectHistory(historyCommand) + showHistoryDropdown = false + }, + ) + } + } + } - is ChatTimelineItem.Tool -> { - "tool:${tail.id}:${tail.output.length}:${tail.isStreaming}:${tail.isCollapsed}" - } + // Output display + Card( + modifier = Modifier.fillMaxWidth().weight(1f), + colors = + androidx.compose.material3.CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Output", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - is ChatTimelineItem.User -> "user:${tail.id}:${tail.text.length}:${tail.imageCount}" - null -> "empty" - } + if (output.isNotEmpty()) { + IconButton( + onClick = { clipboardManager.setText(AnnotatedString(output)) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy output", + modifier = Modifier.size(18.dp), + ) + } + } + } - return "$tailKey:inline=$showInlineRunProgress:count=${timeline.size}" -} + SelectionContainer { + Text( + text = output.ifEmpty { "(no output)" }, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()), + ) + } + } + } -@Composable -private fun rememberIsNearBottom(listState: androidx.compose.foundation.lazy.LazyListState): Boolean { - val isNearBottom by - remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - val lastItemIndex = layoutInfo.totalItemsCount - 1 + // Exit code and truncation info + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (exitCode != null) { + val exitColor = + if (exitCode == 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + AssistChip( + onClick = {}, + label = { + Text( + text = "Exit: $exitCode", + color = exitColor, + ) + }, + ) + } - lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS - } - } - - return isNearBottom -} - -@Composable -private fun rememberShouldStickToBottom( - listState: androidx.compose.foundation.lazy.LazyListState, - isNearBottom: Boolean, - renderedItemsCount: Int, -): androidx.compose.runtime.MutableState { - val shouldStickToBottom = remember { mutableStateOf(true) } - - LaunchedEffect(listState.isScrollInProgress, isNearBottom, renderedItemsCount) { - if (renderedItemsCount <= 1) { - shouldStickToBottom.value = true - return@LaunchedEffect - } - - if (listState.isScrollInProgress) { - shouldStickToBottom.value = isNearBottom - } - } - - return shouldStickToBottom -} - -@Composable -private fun RunActivityAutoScroll( - listState: androidx.compose.foundation.lazy.LazyListState, - latestTimelineActivityKey: String, - renderedItemsCount: Int, - shouldAutoScrollToBottom: Boolean, -) { - var lastAutoScrollAtMs by remember { mutableStateOf(0L) } - - LaunchedEffect(latestTimelineActivityKey, renderedItemsCount, shouldAutoScrollToBottom) { - if (renderedItemsCount <= 0 || !shouldAutoScrollToBottom) { - return@LaunchedEffect - } - - val targetIndex = renderedItemsCount - 1 - val now = System.currentTimeMillis() - - when { - lastAutoScrollAtMs == 0L -> listState.scrollToItem(targetIndex) - now - lastAutoScrollAtMs >= AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS -> - listState.animateScrollToItem(targetIndex) - - else -> listState.scrollToItem(targetIndex) - } - - lastAutoScrollAtMs = now - } -} - -@Composable -private fun RunStreamingAutoScroll( - listState: androidx.compose.foundation.lazy.LazyListState, - isRunActive: Boolean, - shouldAutoScrollToBottom: Boolean, - renderedItemsCount: Int, -) { - LaunchedEffect( - isRunActive, - shouldAutoScrollToBottom, - renderedItemsCount, - listState.isScrollInProgress, - ) { - val shouldRunStreamingAutoScrollLoop = - isRunActive && - shouldAutoScrollToBottom && - renderedItemsCount > 0 && - !listState.isScrollInProgress - if (!shouldRunStreamingAutoScrollLoop) { - return@LaunchedEffect - } - - while (true) { - val targetIndex = renderedItemsCount - 1 - val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - if (lastVisibleIndex < targetIndex) { - listState.scrollToItem(targetIndex) - } - delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) - } - } -} - -@Suppress("LongParameterList") -@Composable -private fun ChatTimelineList( - listState: androidx.compose.foundation.lazy.LazyListState, - timeline: List, - hasOlderMessages: Boolean, - hiddenHistoryCount: Int, - expandedToolArguments: Set, - showInlineRunProgress: Boolean, - runPhase: LiveRunPhase, - runElapsedSeconds: Long, - onLoadOlderMessages: () -> Unit, - onToggleToolExpansion: (String) -> Unit, - onToggleThinkingExpansion: (String) -> Unit, - onToggleDiffExpansion: (String) -> Unit, - onToggleToolArgumentsExpansion: (String) -> Unit, - onPreviewImage: (String) -> Unit, -) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (hasOlderMessages) { - item(key = "load-older-messages") { - TextButton( - onClick = onLoadOlderMessages, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Load older messages ($hiddenHistoryCount hidden)") - } - } - } - - items(items = timeline, key = { item -> item.id }) { item -> - ChatTimelineRow( - item = item, - expandedToolArguments = expandedToolArguments, - onToggleToolExpansion = onToggleToolExpansion, - onToggleThinkingExpansion = onToggleThinkingExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, - onPreviewImage = onPreviewImage, - ) - } - - if (showInlineRunProgress) { - item(key = "inline-run-progress") { - InlineRunProgressCard( - phase = runPhase, - elapsedSeconds = runElapsedSeconds, - ) - } - } - - item(key = CHAT_TIMELINE_BOTTOM_ANCHOR_KEY) { - Spacer(modifier = Modifier.height(1.dp)) - } - } -} - -@Suppress("LongParameterList") -@Composable -private fun ChatTimelineRow( - item: ChatTimelineItem, - expandedToolArguments: Set, - onToggleToolExpansion: (String) -> Unit, - onToggleThinkingExpansion: (String) -> Unit, - onToggleDiffExpansion: (String) -> Unit, - onToggleToolArgumentsExpansion: (String) -> Unit, - onPreviewImage: (String) -> Unit, -) { - when (item) { - is ChatTimelineItem.User -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - UserCard( - text = item.text, - imageCount = item.imageCount, - imageUris = item.imageUris, - onImageClick = onPreviewImage, - ) - } - } - - is ChatTimelineItem.Assistant -> { - AssistantCard( - item = item, - onToggleThinkingExpansion = onToggleThinkingExpansion, - ) - } - - is ChatTimelineItem.Tool -> { - ToolCard( - item = item, - isArgumentsExpanded = item.id in expandedToolArguments, - onToggleToolExpansion = onToggleToolExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, - ) - } - } -} - -@Suppress("LongMethod") -@Composable -private fun UserCard( - text: String, - imageCount: Int, - imageUris: List, - onImageClick: (String) -> Unit, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier.widthIn(max = 340.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "You", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Text( - text = text.ifBlank { "(empty)" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - if (imageUris.isNotEmpty()) { - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed( - items = imageUris.take(MAX_INLINE_USER_IMAGE_PREVIEWS), - key = { index, uri -> "$uri-$index" }, - ) { _, uriString -> - UserImagePreview( - uriString = uriString, - onClick = { onImageClick(uriString) }, - ) - } - - val remaining = imageUris.size - MAX_INLINE_USER_IMAGE_PREVIEWS - if (remaining > 0) { - item(key = "more-images") { - Box( - modifier = - Modifier - .size(56.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Text(text = "+$remaining", style = MaterialTheme.typography.labelMedium) - } - } - } - } - } - - if (imageCount > 0) { - Text( - text = if (imageCount == 1) "📎 1 image attached" else "📎 $imageCount images attached", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } -} - -@Composable -private fun UserImagePreview( - uriString: String, - onClick: () -> Unit, -) { - val uri = remember(uriString) { Uri.parse(uriString) } - var loadFailed by remember(uriString) { mutableStateOf(false) } - - if (loadFailed) { - Box( - modifier = - Modifier - .size(USER_IMAGE_PREVIEW_SIZE_DP.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Text( - text = "IMG", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - return - } - - AsyncImage( - model = uri, - contentDescription = "Sent image preview", - modifier = - Modifier - .size(USER_IMAGE_PREVIEW_SIZE_DP.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick), - contentScale = ContentScale.Crop, - onError = { - loadFailed = true - }, - ) -} - -@Composable -private fun AssistantCard( - item: ChatTimelineItem.Assistant, - onToggleThinkingExpansion: (String) -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val title = if (item.isStreaming) "Assistant (streaming)" else "Assistant" - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - AssistantMessageContent( - text = item.text, - modifier = Modifier.fillMaxWidth(), - ) - - ThinkingBlock( - thinking = item.thinking, - isThinkingComplete = item.isThinkingComplete, - isThinkingExpanded = item.isThinkingExpanded, - itemId = item.id, - onToggleThinkingExpansion = onToggleThinkingExpansion, - ) - } - } -} - -@Composable -private fun AssistantMessageContent( - text: String, - modifier: Modifier = Modifier, -) { - if (text.isBlank()) { - Text( - text = "(empty)", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = modifier, - ) - return - } - - val blocks = remember(text) { parseAssistantMessageBlocks(text) } - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - blocks.forEach { block -> - when (block) { - is AssistantMessageBlock.Paragraph -> { - if (block.text.isNotBlank()) { - MarkdownText( - markdown = block.text, - style = MaterialTheme.typography.bodyMedium, - syntaxHighlightColor = MaterialTheme.colorScheme.surfaceVariant, - syntaxHighlightTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is AssistantMessageBlock.Code -> { - AssistantCodeBlock( - code = block.code, - language = block.language, - ) - } - } - } - } -} - -@Composable -private fun AssistantCodeBlock( - code: String, - language: String?, - modifier: Modifier = Modifier, -) { - val colors = MaterialTheme.colorScheme - val highlighted = highlightCodeBlock(code, language, colors) - - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f), - ) { - SelectionContainer { - Text( - text = highlighted, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(12.dp), - ) - } - } -} - -private sealed interface AssistantMessageBlock { - data class Paragraph( - val text: String, - ) : AssistantMessageBlock - - data class Code( - val code: String, - val language: String?, - ) : AssistantMessageBlock -} - -private fun parseAssistantMessageBlocks(text: String): List { - if (text.isBlank()) return emptyList() - - val blocks = mutableListOf() - var cursor = 0 - - CODE_FENCE_REGEX.findAll(text).forEach { match -> - val matchStart = match.range.first - val matchEndExclusive = match.range.last + 1 - - if (matchStart > cursor) { - val paragraph = text.substring(cursor, matchStart).trim() - if (paragraph.isNotEmpty()) { - blocks += AssistantMessageBlock.Paragraph(paragraph) - } - } - - val language = match.groupValues[1].takeIf { it.isNotBlank() } - val code = match.groupValues[2] - blocks += AssistantMessageBlock.Code(code = code.trimEnd(), language = language) - cursor = matchEndExclusive - } - - if (cursor < text.length) { - val paragraph = text.substring(cursor).trim() - if (paragraph.isNotEmpty()) { - blocks += AssistantMessageBlock.Paragraph(paragraph) - } - } - - return blocks -} - -private fun highlightCodeBlock( - code: String, - language: String?, - colors: androidx.compose.material3.ColorScheme, -): AnnotatedString { - val text = code.ifBlank { "(empty code block)" } - val commentPattern = commentRegexFor(language) - val keywordPattern = keywordRegexFor(language) - - val commentStyle = SpanStyle(color = colors.outline) - val stringStyle = SpanStyle(color = colors.tertiary) - val numberStyle = SpanStyle(color = colors.secondary) - val keywordStyle = SpanStyle(color = colors.primary) - - return buildAnnotatedString { - append(text) - - applyStyle(STRING_REGEX, stringStyle, text) - applyStyle(NUMBER_REGEX, numberStyle, text) - applyStyle(keywordPattern, keywordStyle, text) - applyStyle(commentPattern, commentStyle, text) - } -} - -private fun AnnotatedString.Builder.applyStyle( - regex: Regex, - style: SpanStyle, - text: String, -) { - regex.findAll(text).forEach { match -> - addStyle(style, match.range.first, match.range.last + 1) - } -} - -private fun keywordRegexFor(language: String?): Regex { - return when (language?.lowercase()) { - "kotlin", "kt" -> KOTLIN_KEYWORD_REGEX - "java" -> JAVA_KEYWORD_REGEX - "python", "py" -> PYTHON_KEYWORD_REGEX - "javascript", "js", "typescript", "ts", "tsx" -> JS_TS_KEYWORD_REGEX - "bash", "shell", "sh" -> BASH_KEYWORD_REGEX - else -> GENERIC_KEYWORD_REGEX - } -} - -private fun commentRegexFor(language: String?): Regex { - return when (language?.lowercase()) { - "python", "py", "bash", "shell", "sh", "yaml", "yml" -> HASH_COMMENT_REGEX - else -> SLASH_COMMENT_REGEX - } -} - -@Composable -private fun ThinkingHeader(isThinkingComplete: Boolean) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onTertiaryContainer, - ) - Text( - text = if (isThinkingComplete) " Thinking" else " Thinking…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } -} - -@Composable -private fun ThinkingBlock( - thinking: String?, - isThinkingComplete: Boolean, - isThinkingExpanded: Boolean, - itemId: String, - onToggleThinkingExpansion: (String) -> Unit, -) { - if (thinking == null) return - - val thinkingStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onTertiaryContainer) - val shouldCollapse = thinking.length > THINKING_COLLAPSE_THRESHOLD - val displayThinking = - if (!isThinkingExpanded && shouldCollapse) { - thinking.take(THINKING_COLLAPSE_THRESHOLD) + "…" - } else { - thinking - } - - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f), - ), - border = - androidx.compose.foundation.BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - ThinkingHeader(isThinkingComplete) - MarkdownText( - markdown = displayThinking, - style = thinkingStyle, - syntaxHighlightColor = MaterialTheme.colorScheme.tertiaryContainer, - syntaxHighlightTextColor = MaterialTheme.colorScheme.onTertiaryContainer, - ) - - if (shouldCollapse || isThinkingExpanded) { - TextButton( - onClick = { onToggleThinkingExpansion(itemId) }, - modifier = Modifier.padding(top = 4.dp), - ) { - Text( - if (isThinkingExpanded) "Show less" else "Show more", - ) - } - } - } - } -} - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@Composable -private fun ToolCard( - item: ChatTimelineItem.Tool, - isArgumentsExpanded: Boolean, - onToggleToolExpansion: (String) -> Unit, - onToggleDiffExpansion: (String) -> Unit, - onToggleArgumentsExpansion: (String) -> Unit, -) { - val isEditTool = item.toolName == "edit" && item.editDiff != null - val toolInfo = getToolInfo(item.toolName) - val clipboardManager = LocalClipboardManager.current - - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - // Tool header with icon - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Tool icon with color - Box( - modifier = - Modifier - .size(28.dp) - .clip(RoundedCornerShape(6.dp)) - .background(toolInfo.color.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = toolInfo.icon, - contentDescription = item.toolName, - tint = toolInfo.color, - modifier = Modifier.size(18.dp), - ) - } - - val suffix = - when { - item.isError -> "(error)" - item.isStreaming -> "(running)" - else -> "" - } - - Text( - text = "${item.toolName} $suffix".trim(), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - - if (item.isStreaming) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - } - } - - // Collapsible arguments section - if (item.arguments.isNotEmpty()) { - ToolArgumentsSection( - arguments = item.arguments, - isExpanded = isArgumentsExpanded, - onToggleExpand = { onToggleArgumentsExpansion(item.id) }, - onCopy = { - val argsJson = item.arguments.entries.joinToString("\n") { (k, v) -> "\"$k\": \"$v\"" } - clipboardManager.setText(AnnotatedString("{\n$argsJson\n}")) - }, - ) - } - - // Show diff viewer for edit tools, otherwise show standard output - if (isEditTool && item.editDiff != null) { - DiffViewer( - diffInfo = item.editDiff, - isCollapsed = !item.isDiffExpanded, - onToggleCollapse = { onToggleDiffExpansion(item.id) }, - modifier = Modifier.padding(top = 8.dp), - ) - } else { - val displayOutput = - if (item.isCollapsed && item.output.length > COLLAPSED_OUTPUT_LENGTH) { - item.output.take(COLLAPSED_OUTPUT_LENGTH) + "…" - } else { - item.output - } - - val rawOutput = displayOutput.ifBlank { "(no output yet)" } - val shouldHighlight = !item.isStreaming && rawOutput.length <= TOOL_HIGHLIGHT_MAX_LENGTH - - SelectionContainer { - if (shouldHighlight) { - val inferredLanguage = inferLanguageFromToolContext(item) - val highlightedOutput = - highlightCodeBlock( - code = rawOutput, - language = inferredLanguage, - colors = MaterialTheme.colorScheme, - ) - Text( - text = highlightedOutput, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - ) - } else { - Text( - text = rawOutput, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - ) - } - } - - if (item.output.length > COLLAPSED_OUTPUT_LENGTH) { - TextButton(onClick = { onToggleToolExpansion(item.id) }) { - Icon( - imageVector = if (item.isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text(if (item.isCollapsed) "Expand" else "Collapse") - } - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun ToolArgumentsSection( - arguments: Map, - isExpanded: Boolean, - onToggleExpand: () -> Unit, - onCopy: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.clickable { onToggleExpand() }, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (isExpanded) "Collapse" else "Expand", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Arguments (${arguments.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - IconButton( - onClick = onCopy, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy arguments", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (isExpanded) { - SelectionContainer { - Column( - modifier = Modifier.padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - arguments.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = key, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, - ) - Text( - text = "=", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - val displayValue = - if (value.length > MAX_ARG_DISPLAY_LENGTH) { - value.take(MAX_ARG_DISPLAY_LENGTH) + "…" - } else { - value - } - Text( - text = displayValue, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f), - ) - } - } - } - } - } - } -} - -/** - * Get tool icon and color based on tool name. - */ -@Composable -private fun getToolInfo(toolName: String): ToolDisplayInfo { - val colors = MaterialTheme.colorScheme - return when (toolName) { - "read" -> ToolDisplayInfo(Icons.Default.Description, colors.primary) - "write" -> ToolDisplayInfo(Icons.Default.Edit, colors.secondary) - "edit" -> ToolDisplayInfo(Icons.Default.Edit, colors.tertiary) - "bash" -> ToolDisplayInfo(Icons.Default.Terminal, colors.error) - "grep", "rg", "find" -> ToolDisplayInfo(Icons.Default.Search, colors.primary) - "ls" -> ToolDisplayInfo(Icons.Default.Folder, colors.secondary) - else -> ToolDisplayInfo(Icons.Default.Terminal, colors.outline) - } -} - -private data class ToolDisplayInfo( - val icon: ImageVector, - val color: Color, -) - -private fun inferLanguageFromToolContext(item: ChatTimelineItem.Tool): String? { - val path = item.arguments["path"] ?: return null - val extension = path.substringAfterLast('.', missingDelimiterValue = "").lowercase() - return TOOL_OUTPUT_LANGUAGE_BY_EXTENSION[extension] -} - -@Suppress("LongParameterList", "LongMethod") -@Composable -internal fun PromptControls( - isStreaming: Boolean, - isRetrying: Boolean, - pendingQueueItems: List, - steeringMode: String, - followUpMode: String, - inputText: String, - pendingImages: List, - callbacks: PromptControlsCallbacks, -) { - var showSteerDialog by remember { mutableStateOf(false) } - var showFollowUpDialog by remember { mutableStateOf(false) } - - Column( - modifier = - Modifier - .fillMaxWidth() - .testTag(CHAT_PROMPT_CONTROLS_TAG) - .animateContentSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - AnimatedVisibility( - visible = isStreaming || isRetrying, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - StreamingControls( - isRetrying = isRetrying, - onAbort = callbacks.onAbort, - onAbortRetry = callbacks.onAbortRetry, - onSteerClick = { showSteerDialog = true }, - onFollowUpClick = { showFollowUpDialog = true }, - ) - } - - AnimatedVisibility( - visible = isStreaming && pendingQueueItems.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - PendingQueueInspector( - pendingItems = pendingQueueItems, - steeringMode = steeringMode, - followUpMode = followUpMode, - onRemoveItem = callbacks.onRemovePendingQueueItem, - onClear = callbacks.onClearPendingQueueItems, - ) - } - - PromptInputRow( - inputText = inputText, - isStreaming = isStreaming, - pendingImages = pendingImages, - onInputTextChanged = callbacks.onInputTextChanged, - onSendPrompt = callbacks.onSendPrompt, - onShowCommandPalette = callbacks.onShowCommandPalette, - onAddImage = callbacks.onAddImage, - onRemoveImage = callbacks.onRemoveImage, - ) - } - - if (showSteerDialog) { - SteerFollowUpDialog( - title = "Steer", - onDismiss = { showSteerDialog = false }, - onConfirm = { message -> - callbacks.onSteer(message) - showSteerDialog = false - }, - ) - } - - if (showFollowUpDialog) { - SteerFollowUpDialog( - title = "Follow Up", - onDismiss = { showFollowUpDialog = false }, - onConfirm = { message -> - callbacks.onFollowUp(message) - showFollowUpDialog = false - }, - ) - } -} - -@Suppress("LongMethod") -@Composable -private fun StreamingControls( - isRetrying: Boolean, - onAbort: () -> Unit, - onAbortRetry: () -> Unit, - onSteerClick: () -> Unit, - onFollowUpClick: () -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth().testTag(CHAT_STREAMING_CONTROLS_TAG), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Button( - onClick = onAbort, - modifier = Modifier.weight(1f), - colors = - androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 8.dp), - ) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.padding(end = 4.dp), - ) - Text( - text = "Abort", - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - } - - if (isRetrying) { - OutlinedButton( - onClick = onAbortRetry, - modifier = Modifier.weight(1f), - ) { - Text(text = "Abort Retry", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) - } - } - } - - if (!isRetrying) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = onSteerClick, - modifier = Modifier.weight(1f), - ) { - Text(text = "Steer", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) - } - - OutlinedButton( - onClick = onFollowUpClick, - modifier = Modifier.weight(1f), - ) { - Text(text = "Follow Up", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) - } - } - } - } -} - -@Composable -private fun PendingQueueInspector( - pendingItems: List, - steeringMode: String, - followUpMode: String, - onRemoveItem: (String) -> Unit, - onClear: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Pending queue (${pendingItems.size})", - style = MaterialTheme.typography.bodyMedium, - ) - TextButton(onClick = onClear) { - Text("Clear") - } - } - - Text( - text = "Steer: ${deliveryModeLabel(steeringMode)} · Follow-up: ${deliveryModeLabel(followUpMode)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - pendingItems.forEach { item -> - PendingQueueItemRow( - item = item, - onRemove = { onRemoveItem(item.id) }, - ) - } - - Text( - text = "Items shown here were sent while streaming; clearing only removes local inspector entries.", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun PendingQueueItemRow( - item: PendingQueueItem, - onRemove: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - val typeLabel = - when (item.type) { - PendingQueueType.STEER -> "Steer" - PendingQueueType.FOLLOW_UP -> "Follow-up" - } - Text( - text = "$typeLabel · ${deliveryModeLabel(item.mode)}", - style = MaterialTheme.typography.labelMedium, - ) - Text( - text = item.message, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - ) - } - - TextButton(onClick = onRemove) { - Text("Remove") - } - } -} - -private fun deliveryModeLabel(mode: String): String { - return when (mode) { - ChatViewModel.DELIVERY_MODE_ONE_AT_A_TIME -> "one-at-a-time" - else -> "all" - } -} - -@Suppress("LongMethod", "LongParameterList") -@Composable -internal fun PromptInputRow( - inputText: String, - isStreaming: Boolean, - pendingImages: List, - onInputTextChanged: (String) -> Unit, - onSendPrompt: () -> Unit, - onShowCommandPalette: () -> Unit = {}, - onAddImage: (PendingImage) -> Unit, - onRemoveImage: (Int) -> Unit, -) { - val context = LocalContext.current - val imageEncoder = remember { ImageEncoder(context) } - var previewImageUri by rememberSaveable { mutableStateOf(null) } - - val submitPrompt = { - onSendPrompt() - } - - val photoPickerLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickMultipleVisualMedia(), - ) { uris -> - uris.forEach { uri -> - imageEncoder.getImageInfo(uri)?.let { info -> onAddImage(info) } - } - } - - Column(modifier = Modifier.fillMaxWidth().testTag(CHAT_PROMPT_INPUT_ROW_TAG)) { - // Pending images strip - if (pendingImages.isNotEmpty()) { - ImageAttachmentStrip( - images = pendingImages, - onRemove = onRemoveImage, - onImageClick = { uri -> - previewImageUri = uri - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Attachment button - IconButton( - onClick = { - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), - ) - }, - enabled = !isStreaming, - ) { - Icon( - imageVector = Icons.Default.AttachFile, - contentDescription = "Attach Image", - ) - } - - OutlinedTextField( - value = inputText, - onValueChange = onInputTextChanged, - modifier = Modifier.weight(1f), - placeholder = { Text("Type a message...") }, - singleLine = false, - maxLines = 8, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), - enabled = !isStreaming, - trailingIcon = { - if (inputText.isEmpty() && !isStreaming) { - IconButton(onClick = onShowCommandPalette) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Commands", - ) - } - } - }, - ) - - IconButton( - onClick = submitPrompt, - enabled = (inputText.isNotBlank() || pendingImages.isNotEmpty()) && !isStreaming, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - ) - } - } - - previewImageUri?.let { uri -> - ImagePreviewDialog( - uriString = uri, - onDismiss = { previewImageUri = null }, - ) - } - } -} - -@Composable -private fun ImageAttachmentStrip( - images: List, - onRemove: (Int) -> Unit, - onImageClick: (String) -> Unit, -) { - LazyRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - itemsIndexed( - items = images, - key = { index, image -> "${image.uri}-$index" }, - ) { index, image -> - ImageThumbnail( - image = image, - onRemove = { onRemove(index) }, - onClick = { onImageClick(image.uri) }, - ) - } - } -} - -@Suppress("MagicNumber", "LongMethod") -@Composable -private fun ImageThumbnail( - image: PendingImage, - onRemove: () -> Unit, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .size(72.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - val uri = remember(image.uri) { Uri.parse(image.uri) } - AsyncImage( - model = uri, - contentDescription = image.displayName, - modifier = Modifier.fillMaxSize().clickable(onClick = onClick), - contentScale = ContentScale.Crop, - ) - - // Size warning badge - if (image.sizeBytes > ImageEncoder.MAX_IMAGE_SIZE_BYTES) { - Box( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(2.dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.error) - .padding(horizontal = 4.dp, vertical = 2.dp), - ) { - Text( - text = ">5MB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onError, - ) - } - } - - // Remove button - IconButton( - onClick = onRemove, - modifier = - Modifier - .align(Alignment.TopEnd) - .size(32.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - ) - } - - // File name / size label - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) - .padding(2.dp), - ) { - Text( - text = formatFileSize(image.sizeBytes), - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - modifier = Modifier.align(Alignment.Center), - ) - } - } -} - -@Suppress("MagicNumber") -private fun formatFileSize(bytes: Long): String { - return when { - bytes >= 1_048_576 -> String.format(java.util.Locale.US, "%.1fMB", bytes / 1_048_576.0) - bytes >= 1_024 -> String.format(java.util.Locale.US, "%.0fKB", bytes / 1_024.0) - else -> "${bytes}B" - } -} - -@Composable -private fun ImagePreviewDialog( - uriString: String, - onDismiss: () -> Unit, -) { - val uri = remember(uriString) { Uri.parse(uriString) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - modifier = Modifier.fillMaxSize().background(Color.Black), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = uri, - contentDescription = "Image preview", - modifier = Modifier.fillMaxSize().padding(16.dp), - contentScale = ContentScale.Fit, - ) - - IconButton( - onClick = onDismiss, - modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close image preview", - tint = Color.White, - ) - } - } - } -} - -@Composable -private fun SteerFollowUpDialog( - title: String, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - var text by rememberSaveable { mutableStateOf("") } - - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - placeholder = { Text("Enter your message...") }, - modifier = Modifier.fillMaxWidth(), - singleLine = false, - maxLines = 6, - ) - }, - confirmButton = { - Button( - onClick = { onConfirm(text) }, - enabled = text.isNotBlank(), - ) { - Text("Send") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -@Suppress("LongMethod", "LongParameterList") -@Composable -private fun ModelThinkingControls( - currentModel: ModelInfo?, - thinkingLevel: String?, - onSetThinkingLevel: (String) -> Unit, - onShowModelPicker: () -> Unit, -) { - var showThinkingMenu by remember { mutableStateOf(false) } - - val modelText = currentModel?.name ?: "Select model" - val thinkingText = thinkingLevel?.uppercase() ?: "OFF" - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = onShowModelPicker, - modifier = Modifier.weight(1f), - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 12.dp, - vertical = 6.dp, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = modelText, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - ) - } - } - - // Thinking level selector - Box(modifier = Modifier.wrapContentWidth()) { - OutlinedButton( - onClick = { showThinkingMenu = true }, - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 12.dp, - vertical = 6.dp, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = thinkingText, - style = MaterialTheme.typography.labelMedium, - ) - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } - - DropdownMenu( - expanded = showThinkingMenu, - onDismissRequest = { showThinkingMenu = false }, - ) { - THINKING_LEVEL_OPTIONS.forEach { level -> - DropdownMenuItem( - text = { Text(level.replaceFirstChar { it.uppercase() }) }, - onClick = { - onSetThinkingLevel(level) - showThinkingMenu = false - }, - ) - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun ExtensionStatusStrip(statuses: Map) { - if (statuses.isEmpty()) return - - var expanded by rememberSaveable { mutableStateOf(false) } - var previousStatuses by remember { mutableStateOf>(emptyMap()) } - var hasPreviousSnapshot by remember { mutableStateOf(false) } - - val comparisonSnapshot = - if (hasPreviousSnapshot) { - previousStatuses - } else { - statuses.mapValues { (_, value) -> value.trim() } - } - - val presentation = - remember(statuses, comparisonSnapshot, expanded) { - buildExtensionStatusPresentation( - statuses = statuses, - previousStatuses = comparisonSnapshot, - expanded = expanded, - ) - } - - LaunchedEffect(statuses) { - previousStatuses = statuses.mapValues { (_, value) -> value.trim() } - hasPreviousSnapshot = true - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f), - tonalElevation = 1.dp, - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { - Text( - text = "Extension status", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "${presentation.activeCount} active · ${presentation.quietCount} quiet", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - TextButton(onClick = { expanded = !expanded }) { - Text(if (expanded) "Hide" else "Show") - } - } - - if (!expanded) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val first = presentation.visibleEntries.firstOrNull() - if (first != null) { - StatusPill(entry = first) - } - if (presentation.hiddenCount > 0) { - Text( - text = "+${presentation.hiddenCount} more", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } - } - - AnimatedVisibility(visible = expanded) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - presentation.visibleEntries.forEach { entry -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - text = if (entry.isChanged) "•" else "", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.padding(top = 2.dp), - ) - Text( - text = "${entry.key}: ${entry.value.take(STATUS_VALUE_MAX_LENGTH)}", - style = MaterialTheme.typography.bodySmall, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - } - } - } - - if (presentation.changedCount > 0) { - Text( - text = "${presentation.changedCount} update(s) since last refresh", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - ) - } - } - } -} - -@Composable -private fun StatusPill(entry: ExtensionStatusEntry) { - Surface( - shape = RoundedCornerShape(14.dp), - color = - if (entry.isLowSignal) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.secondaryContainer - }, - ) { - Text( - text = "${entry.key}: ${entry.value.take(EXTENSION_STATUS_PILL_MAX_LENGTH)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - } -} - -internal data class ExtensionStatusEntry( - val key: String, - val value: String, - val isLowSignal: Boolean, - val isChanged: Boolean, -) - -internal data class ExtensionStatusPresentation( - val visibleEntries: List, - val hiddenCount: Int, - val activeCount: Int, - val quietCount: Int, - val changedCount: Int, -) - -internal fun buildExtensionStatusPresentation( - statuses: Map, - previousStatuses: Map, - expanded: Boolean, -): ExtensionStatusPresentation { - if (statuses.isEmpty()) { - return ExtensionStatusPresentation( - visibleEntries = emptyList(), - hiddenCount = 0, - activeCount = 0, - quietCount = 0, - changedCount = 0, - ) - } - - val entries = - statuses - .toSortedMap() - .map { (key, rawValue) -> - val value = rawValue.trim().ifEmpty { "(empty)" } - ExtensionStatusEntry( - key = key, - value = value, - isLowSignal = isLowSignalExtensionStatus(value), - isChanged = previousStatuses[key] != value, - ) - } - - val changed = entries.filter { it.isChanged } - val active = entries.filterNot { it.isLowSignal } - val quietCount = entries.size - active.size - - val compactCandidates = - when { - changed.isNotEmpty() -> changed - active.isNotEmpty() -> active - else -> entries - } - - val visibleEntries = if (expanded) entries else compactCandidates.take(MAX_COMPACT_EXTENSION_STATUS_ITEMS) - - return ExtensionStatusPresentation( - visibleEntries = visibleEntries, - hiddenCount = if (expanded) 0 else (entries.size - visibleEntries.size).coerceAtLeast(0), - activeCount = active.size, - quietCount = quietCount, - changedCount = changed.size, - ) -} - -internal fun isLowSignalExtensionStatus(value: String): Boolean { - val normalized = value.trim().lowercase() - return LOW_SIGNAL_STATUS_TOKENS.any { token -> normalized.contains(token) } -} - -@Composable -private fun ExtensionWidgets( - widgets: Map, - placement: String, -) { - val matchingWidgets = widgets.values.filter { it.placement == placement } - - matchingWidgets.forEach { widget -> - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier.padding(8.dp), - ) { - widget.lines.forEach { line -> - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -internal const val CHAT_PROMPT_CONTROLS_TAG = "chat_prompt_controls" -internal const val CHAT_STREAMING_CONTROLS_TAG = "chat_streaming_controls" -internal const val CHAT_PROMPT_INPUT_ROW_TAG = "chat_prompt_input_row" -internal const val CHAT_RUN_PROGRESS_TAG = "chat_run_progress" -internal const val CHAT_JUMP_TO_LATEST_TAG = "chat_jump_to_latest" - -private const val COLLAPSED_OUTPUT_LENGTH = 280 -private const val THINKING_COLLAPSE_THRESHOLD = 280 -private const val MAX_ARG_DISPLAY_LENGTH = 100 -private const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 -private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 -private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 -private const val AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS = 120L -private const val STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS = 90L -private const val CHAT_TIMELINE_BOTTOM_ANCHOR_KEY = "chat_timeline_bottom_anchor" -private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 -private const val STATUS_VALUE_MAX_LENGTH = 180 -private const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 -private const val MAX_COMPACT_EXTENSION_STATUS_ITEMS = 2 -private const val CONTEXT_PERCENT_FACTOR = 100.0 -private const val CONTEXT_PERCENT_MIN = 0 -private const val CONTEXT_PERCENT_MAX = 100 -private const val MODEL_PICKER_SCROLL_OFFSET_ITEMS = 1 -private const val RUN_PROGRESS_TICK_MS = 1_000L -private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" -private val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh") -private val CODE_FENCE_REGEX = Regex("```([\\w+-]*)\\r?\\n([\\s\\S]*?)```") -private val STRING_REGEX = Regex("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'") -private val NUMBER_REGEX = Regex("\\b\\d+(?:\\.\\d+)?\\b") -private val HASH_COMMENT_REGEX = Regex("#.*$", setOf(RegexOption.MULTILINE)) -private val SLASH_COMMENT_REGEX = - Regex( - "//.*$|/\\*.*?\\*/", - setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL), - ) -private val KOTLIN_KEYWORD_REGEX = - Regex( - "\\b(class|object|interface|fun|val|var|when|if|else|return|suspend|data|sealed|" + - "private|public|override|import|package)\\b", - ) -private val JAVA_KEYWORD_REGEX = - Regex( - "\\b(class|interface|enum|public|private|protected|static|final|void|return|if|" + - "else|switch|case|new|import|package)\\b", - ) -private val PYTHON_KEYWORD_REGEX = - Regex( - "\\b(def|class|import|from|as|if|elif|else|for|while|return|try|except|with|lambda|pass|break|continue)\\b", - ) -private val JS_TS_KEYWORD_REGEX = - Regex( - "\\b(function|class|const|let|var|return|if|else|switch|case|import|from|export|async|await|interface|type)\\b", - ) -private val BASH_KEYWORD_REGEX = Regex("\\b(if|then|fi|for|do|done|case|esac|function|export|echo)\\b") -private val GENERIC_KEYWORD_REGEX = - Regex("\\b(if|else|for|while|return|class|function|import|from|const|let|var|def|public|private)\\b") -private val TOOL_OUTPUT_LANGUAGE_BY_EXTENSION = - mapOf( - "kt" to "kotlin", - "kts" to "kotlin", - "java" to "java", - "js" to "javascript", - "jsx" to "javascript", - "ts" to "typescript", - "tsx" to "typescript", - "py" to "python", - "json" to "json", - "jsonl" to "json", - "xml" to "xml", - "html" to "xml", - "svg" to "xml", - "sh" to "bash", - "bash" to "bash", - "sql" to "sql", - "yml" to "yaml", - "yaml" to "yaml", - "go" to "go", - "rs" to "rust", - "md" to "markdown", - ) -private val LOW_SIGNAL_STATUS_TOKENS = - setOf( - "idle", - "ready", - "ok", - "connected", - "none", - "no updates", - "synced", - ) - -@Suppress("LongParameterList", "LongMethod") -@Composable -private fun BashDialog( - isVisible: Boolean, - command: String, - output: String, - exitCode: Int?, - isExecuting: Boolean, - wasTruncated: Boolean, - fullLogPath: String?, - history: List, - onCommandChange: (String) -> Unit, - onExecute: () -> Unit, - onAbort: () -> Unit, - onSelectHistory: (String) -> Unit, - onDismiss: () -> Unit, -) { - if (!isVisible) return - - var showHistoryDropdown by remember { mutableStateOf(false) } - val clipboardManager = LocalClipboardManager.current - - androidx.compose.material3.AlertDialog( - onDismissRequest = { if (!isExecuting) onDismiss() }, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Run Bash Command") - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - text = { - Column( - modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp, max = 400.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Command input with history dropdown - Box { - OutlinedTextField( - value = command, - onValueChange = onCommandChange, - placeholder = { Text("Enter command...") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !isExecuting, - textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - trailingIcon = { - if (history.isNotEmpty() && !isExecuting) { - IconButton(onClick = { showHistoryDropdown = true }) { - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = "History", - ) - } - } - }, - ) - - DropdownMenu( - expanded = showHistoryDropdown, - onDismissRequest = { showHistoryDropdown = false }, - ) { - history.forEach { historyCommand -> - DropdownMenuItem( - text = { - Text( - text = historyCommand, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - maxLines = 1, - ) - }, - onClick = { - onSelectHistory(historyCommand) - showHistoryDropdown = false - }, - ) - } - } - } - - // Output display - Card( - modifier = Modifier.fillMaxWidth().weight(1f), - colors = - androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column( - modifier = Modifier.fillMaxSize().padding(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Output", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (output.isNotEmpty()) { - IconButton( - onClick = { clipboardManager.setText(AnnotatedString(output)) }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy output", - modifier = Modifier.size(18.dp), - ) - } - } - } - - SelectionContainer { - Text( - text = output.ifEmpty { "(no output)" }, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()), - ) - } - } - } - - // Exit code and truncation info - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - if (exitCode != null) { - val exitColor = - if (exitCode == 0) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - } - AssistChip( - onClick = {}, - label = { - Text( - text = "Exit: $exitCode", - color = exitColor, - ) - }, - ) - } - - if (wasTruncated && fullLogPath != null) { - TextButton( - onClick = { clipboardManager.setText(AnnotatedString(fullLogPath)) }, - ) { - Text( - text = "Output truncated (copy path)", - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - }, - confirmButton = { - if (isExecuting) { - Button( - onClick = onAbort, - colors = - androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.size(18.dp).padding(end = 4.dp), - ) - Text("Abort") - } - } else { - Button( - onClick = onExecute, - enabled = command.isNotBlank(), - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp).padding(end = 4.dp), - ) - Text("Execute") - } - } - }, - dismissButton = { - if (!isExecuting) { - TextButton(onClick = onDismiss) { - Text("Close") - } - } - }, - ) -} - -@Suppress("LongParameterList", "LongMethod") -@Composable -private fun SessionStatsSheet( - isVisible: Boolean, - stats: SessionStats?, - sessionName: String?, - pendingMessageCount: Int, - isLoading: Boolean, - onRefresh: () -> Unit, - onDismiss: () -> Unit, -) { - if (!isVisible) return - - val clipboardManager = LocalClipboardManager.current - - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Session Statistics") - IconButton(onClick = onRefresh) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Refresh", - ) - } - } - }, - text = { - if (isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } else if (stats == null) { - Text( - text = "No statistics available", - style = MaterialTheme.typography.bodyMedium, - ) - } else { - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (sessionName != null || pendingMessageCount > 0) { - StatsSection(title = "Session") { - sessionName?.let { StatRow("Name", it) } - if (pendingMessageCount > 0) { - StatRow("Queued Messages", pendingMessageCount.toString()) - } - } - } - - // Token stats - StatsSection(title = "Tokens") { - StatRow("Input Tokens", formatNumber(stats.inputTokens)) - StatRow("Output Tokens", formatNumber(stats.outputTokens)) - StatRow("Cache Read", formatNumber(stats.cacheReadTokens)) - StatRow("Cache Write", formatNumber(stats.cacheWriteTokens)) - } - - // Cost - StatsSection(title = "Cost") { - StatRow("Total Cost", formatCost(stats.totalCost)) - } - - // Messages - StatsSection(title = "Messages") { - StatRow("Total", stats.messageCount.toString()) - StatRow("User", stats.userMessageCount.toString()) - StatRow("Assistant", stats.assistantMessageCount.toString()) - StatRow("Tool Results", stats.toolResultCount.toString()) - } - - // Session path - stats.sessionPath?.let { path -> - StatsSection(title = "Session File") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = path.takeLast(SESSION_PATH_DISPLAY_LENGTH), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = { clipboardManager.setText(AnnotatedString(path)) }, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy path", - modifier = Modifier.size(16.dp), - ) - } - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Close") - } - }, - ) -} - -@Composable -private fun StatsSection( - title: String, - content: @Composable () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(12.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp), - ) - content() - } -} - -@Composable -private fun StatRow( - label: String, - value: String, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - ) - } -} - -@Suppress("MagicNumber") -private fun formatNumber(value: Long): String { - return when { - value >= 1_000_000 -> String.format(java.util.Locale.US, "%.2fM", value / 1_000_000.0) - value >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", value / 1_000.0) - else -> value.toString() - } -} - -private fun formatContextUsageLabel( - stats: SessionStats?, - currentModel: ModelInfo?, -): String { - val statsSnapshot = stats ?: return "Ctx --" - - val explicitUsedTokens = statsSnapshot.contextUsedTokens?.coerceAtLeast(0L) - val explicitWindowTokens = statsSnapshot.contextWindowTokens?.takeIf { it > 0 } - val explicitPercent = statsSnapshot.contextUsagePercent?.coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) - - val fallbackUsedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) - val fallbackWindowTokens = currentModel?.contextWindow?.takeIf { it > 0 }?.toLong() - val approximateWindowTokens = explicitWindowTokens ?: fallbackWindowTokens - - val contextUsage = - buildContextUsageCoreLabel( - explicitUsedTokens = explicitUsedTokens, - explicitWindowTokens = explicitWindowTokens, - explicitPercent = explicitPercent, - fallbackUsedTokens = fallbackUsedTokens, - fallbackWindowTokens = approximateWindowTokens, - ) - - val compactionLabel = - statsSnapshot.compactionCount - .takeIf { it > 0 } - ?.let { count -> " · C$count" } - .orEmpty() - - val costLabel = - statsSnapshot.totalCost - .takeIf { it > 0.0 } - ?.let { cost -> " · ${formatCompactCost(cost)}" } - .orEmpty() - - return contextUsage + compactionLabel + costLabel -} - -private fun buildContextUsageCoreLabel( - explicitUsedTokens: Long?, - explicitWindowTokens: Long?, - explicitPercent: Int?, - fallbackUsedTokens: Long, - fallbackWindowTokens: Long?, -): String { - val explicitPercentLabel = - when { - explicitPercent == null -> null - explicitUsedTokens != null && explicitWindowTokens != null -> - formatExactContextUsage( - percent = explicitPercent, - usedTokens = explicitUsedTokens, - windowTokens = explicitWindowTokens, - ) - - else -> "Ctx $explicitPercent%" - } - - if (explicitPercentLabel != null) { - return explicitPercentLabel - } - - val explicitUsageLabel = - when { - explicitUsedTokens != null && explicitWindowTokens != null -> { - val computedPercent = computeContextPercent(explicitUsedTokens, explicitWindowTokens) - formatExactContextUsage( - percent = computedPercent, - usedTokens = explicitUsedTokens, - windowTokens = explicitWindowTokens, - ) - } - - explicitUsedTokens != null -> "Ctx ${formatNumber(explicitUsedTokens)}" - fallbackWindowTokens != null -> - "Ctx ~${formatNumber(fallbackUsedTokens)}/${formatNumber(fallbackWindowTokens)}" - - else -> "Ctx ~${formatNumber(fallbackUsedTokens)}" - } - - return explicitUsageLabel -} - -private fun computeContextPercent( - usedTokens: Long, - windowTokens: Long, -): Int { - return ((usedTokens * CONTEXT_PERCENT_FACTOR) / windowTokens.toDouble()) - .toInt() - .coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) -} - -private fun formatExactContextUsage( - percent: Int, - usedTokens: Long, - windowTokens: Long, -): String { - return "Ctx $percent% · ${formatNumber(usedTokens)}/${formatNumber(windowTokens)}" -} - -@Suppress("MagicNumber") -private fun formatCost(value: Double): String { - return String.format(java.util.Locale.US, "$%.4f", value) -} - -@Suppress("MagicNumber") -private fun formatCompactCost(value: Double): String { - val pattern = - when { - value >= 1.0 -> "$%.2f" - value >= 0.1 -> "$%.3f" - else -> "$%.4f" - } - return String.format(java.util.Locale.US, pattern, value) -} - -@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") -@Composable -private fun ModelPickerSheet( - isVisible: Boolean, - models: List, - currentModel: ModelInfo?, - query: String, - isLoading: Boolean, - onQueryChange: (String) -> Unit, - onSelectModel: (AvailableModel) -> Unit, - onDismiss: () -> Unit, -) { - if (!isVisible) return - - val filteredModels = - remember(models, query) { - if (query.isBlank()) { - models - } else { - models.filter { model -> - model.name.contains(query, ignoreCase = true) || - model.provider.contains(query, ignoreCase = true) || - model.id.contains(query, ignoreCase = true) - } - } - } - - val groupedModels = - remember(filteredModels) { - filteredModels.groupBy { it.provider } - } - val listState = androidx.compose.foundation.lazy.rememberLazyListState() - val selectedModelIndex = - remember(groupedModels, currentModel) { - if (currentModel == null) { - -1 - } else { - var index = 0 - var foundIndex = -1 - groupedModels.forEach { (_, modelsInGroup) -> - index += 1 // provider header item - modelsInGroup.forEach { model -> - if ( - foundIndex < 0 && - model.id == currentModel.id && - model.provider == currentModel.provider - ) { - foundIndex = index - } - index += 1 - } - } - foundIndex - } - } - - LaunchedEffect(selectedModelIndex, isVisible) { - if (isVisible && selectedModelIndex >= 0) { - listState.scrollToItem((selectedModelIndex - MODEL_PICKER_SCROLL_OFFSET_ITEMS).coerceAtLeast(0)) - } - } - - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Select Model") }, - text = { - Column( - modifier = Modifier.fillMaxWidth().heightIn(max = 500.dp), - ) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - placeholder = { Text("Search models...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - ) - }, - ) - - if (isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } else if (filteredModels.isEmpty()) { - Text( - text = "No models found", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth(), - ) { - groupedModels.forEach { (provider, modelsInGroup) -> - item { - Text( - text = provider.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - items( - items = modelsInGroup, - key = { model -> "${model.provider}:${model.id}" }, - ) { model -> - ModelItem( - model = model, - isSelected = - currentModel?.id == model.id && - currentModel.provider == model.provider, - onClick = { onSelectModel(model) }, - ) - } - } - } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -@Suppress("LongMethod") -@Composable -private fun ModelItem( - model: AvailableModel, - isSelected: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { onClick() }, - colors = - if (isSelected) { - androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) - } else { - androidx.compose.material3.CardDefaults.cardColors() - }, - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = model.name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - if (model.supportsThinking) { - AssistChip( - onClick = {}, - label = { - Text( - "Thinking", - style = MaterialTheme.typography.labelSmall, - ) - }, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - model.contextWindow?.let { ctx -> - Text( - text = "Context: ${formatNumber(ctx.toLong())}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - model.inputCostPer1k?.let { cost -> - Text( - text = "In: \$${String.format(java.util.Locale.US, "%.4f", cost)}/1k", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - model.outputCostPer1k?.let { cost -> - Text( - text = "Out: \$${String.format(java.util.Locale.US, "%.4f", cost)}/1k", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -@Suppress("LongParameterList", "LongMethod") -@Composable -private fun TreeNavigationSheet( - isVisible: Boolean, - tree: SessionTreeSnapshot?, - selectedFilter: String, - isLoading: Boolean, - errorMessage: String?, - onFilterChange: (String) -> Unit, - onForkFromEntry: (String) -> Unit, - onJumpAndContinue: (String) -> Unit, - onDismiss: () -> Unit, -) { - if (!isVisible) return - - val entries = tree?.entries.orEmpty() - val depthByEntry = remember(entries) { computeDepthMap(entries) } - val childCountByEntry = remember(entries) { computeChildCountMap(entries) } - - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Session tree") }, - text = { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp)) { - tree?.sessionPath?.let { sessionPath -> - Text( - text = truncatePath(sessionPath), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - - // Scrollable filter chips to avoid overflow - LazyRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - items( - items = TREE_FILTER_OPTIONS, - key = { (filter, _) -> filter }, - ) { (filter, label) -> - FilterChip( - selected = filter == selectedFilter, - onClick = { onFilterChange(filter) }, - label = { Text(label, style = MaterialTheme.typography.labelSmall) }, - ) - } - } - - when { - isLoading -> { - Box( - modifier = Modifier.fillMaxWidth().padding(24.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - errorMessage != null -> { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - entries.isEmpty() -> { - Text( - text = "No tree data available", - style = MaterialTheme.typography.bodyMedium, - ) - } - - else -> { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items( - items = entries, - key = { entry -> entry.entryId }, - ) { entry -> - TreeEntryRow( - entry = entry, - depth = depthByEntry[entry.entryId] ?: 0, - childCount = childCountByEntry[entry.entryId] ?: 0, - isCurrent = tree?.currentLeafId == entry.entryId, - onForkFromEntry = onForkFromEntry, - onJumpAndContinue = onJumpAndContinue, - ) - } - } - } - } + if (wasTruncated && fullLogPath != null) { + TextButton( + onClick = { clipboardManager.setText(AnnotatedString(fullLogPath)) }, + ) { + Text( + text = "Output truncated (copy path)", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } } }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Close") - } - }, - ) -} - -@Suppress("MagicNumber", "LongMethod", "LongParameterList") -@Composable -private fun TreeEntryRow( - entry: SessionTreeEntry, - depth: Int, - childCount: Int, - isCurrent: Boolean, - onForkFromEntry: (String) -> Unit, - onJumpAndContinue: (String) -> Unit, -) { - val indent = (depth * 8).dp - val isMessage = entry.entryType == "message" - val containerColor = - when { - isCurrent -> MaterialTheme.colorScheme.primaryContainer - isMessage -> MaterialTheme.colorScheme.surface - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } - val contentColor = - when { - isCurrent -> MaterialTheme.colorScheme.onPrimaryContainer - else -> MaterialTheme.colorScheme.onSurface - } - - Card( - modifier = Modifier.fillMaxWidth().padding(start = indent), - colors = - CardDefaults.cardColors( - containerColor = containerColor, - ), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, + confirmButton = { + if (isExecuting) { + Button( + onClick = onAbort, + colors = + androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { - val typeIcon = treeEntryIcon(entry.entryType) Icon( - imageVector = typeIcon, + imageVector = Icons.Default.Stop, contentDescription = null, - modifier = Modifier.size(14.dp), - tint = contentColor.copy(alpha = 0.7f), - ) - val label = - buildString { - append(entry.entryType.replace('_', ' ')) - entry.role?.let { append(" · $it") } - } - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = contentColor.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp).padding(end = 4.dp), ) + Text("Abort") } - - if (isCurrent) { - Text( - text = "● current", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + } else { + Button( + onClick = onExecute, + enabled = command.isNotBlank(), + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(end = 4.dp), ) + Text("Execute") } } - - if (isMessage) { - Text( - text = entry.preview, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - color = contentColor, - ) - } - - if (entry.isBookmarked && !entry.label.isNullOrBlank()) { - Text( - text = "🔖 ${entry.label}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - if (childCount > 1) { - Text( - text = "↳ $childCount branches", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { - TextButton( - onClick = { onJumpAndContinue(entry.entryId) }, - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 8.dp, - vertical = 0.dp, - ), - ) { - Text("Jump", style = MaterialTheme.typography.labelSmall) - } - TextButton( - onClick = { onForkFromEntry(entry.entryId) }, - contentPadding = - androidx.compose.foundation.layout.PaddingValues( - horizontal = 8.dp, - vertical = 0.dp, - ), - ) { - Text("Fork", style = MaterialTheme.typography.labelSmall) - } + }, + dismissButton = { + if (!isExecuting) { + TextButton(onClick = onDismiss) { + Text("Close") } } - } - } -} - -private fun treeEntryIcon(entryType: String): ImageVector { - return when (entryType) { - "message" -> Icons.Default.Description - "model_change" -> Icons.Default.Refresh - "thinking_level_change" -> Icons.Default.Menu - else -> Icons.Default.PlayArrow - } -} - -@Suppress("ReturnCount") -private fun computeDepthMap(entries: List): Map { - val byId = entries.associateBy { it.entryId } - val memo = mutableMapOf() - - fun depth( - entryId: String, - stack: MutableSet, - ): Int { - memo[entryId]?.let { return it } - if (!stack.add(entryId)) { - return 0 - } - - val entry = byId[entryId] - val resolvedDepth = - when { - entry == null -> 0 - entry.parentId == null -> 0 - else -> depth(entry.parentId, stack) + 1 - } - - stack.remove(entryId) - memo[entryId] = resolvedDepth - return resolvedDepth - } - - entries.forEach { entry -> depth(entry.entryId, mutableSetOf()) } - return memo -} - -private fun computeChildCountMap(entries: List): Map { - return entries - .groupingBy { it.parentId } - .eachCount() - .mapNotNull { (parentId, count) -> - parentId?.let { it to count } - }.toMap() -} - -private val TREE_FILTER_OPTIONS = - listOf( - ChatViewModel.TREE_FILTER_DEFAULT to "default", - ChatViewModel.TREE_FILTER_ALL to "all", - ChatViewModel.TREE_FILTER_NO_TOOLS to "no-tools", - ChatViewModel.TREE_FILTER_USER_ONLY to "user-only", - ChatViewModel.TREE_FILTER_LABELED_ONLY to "labeled-only", + }, ) - -private const val SESSION_PATH_DISPLAY_LENGTH = 40 - -private fun truncatePath(path: String): String { - if (path.length <= SESSION_PATH_DISPLAY_LENGTH) { - return path - } - val head = SESSION_PATH_DISPLAY_LENGTH / 2 - val tail = SESSION_PATH_DISPLAY_LENGTH - head - 1 - return "${path.take(head)}…${path.takeLast(tail)}" } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatsFormatting.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatsFormatting.kt new file mode 100644 index 0000000..abefec2 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatsFormatting.kt @@ -0,0 +1,102 @@ +package com.ayagmar.pimobile.ui.chat + +import com.ayagmar.pimobile.corerpc.SessionStats +import com.ayagmar.pimobile.sessions.ModelInfo + +@Suppress("MagicNumber") +internal fun formatNumber(value: Long): String { + return when { + value >= 1_000_000 -> String.format(java.util.Locale.US, "%.2fM", value / 1_000_000.0) + value >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", value / 1_000.0) + else -> value.toString() + } +} + +internal fun formatContextUsageLabel( + stats: SessionStats?, + currentModel: ModelInfo?, +): String { + val statsSnapshot = stats ?: return "Ctx --" + + val explicitUsedTokens = statsSnapshot.contextUsedTokens?.coerceAtLeast(0L) + val explicitWindowTokens = statsSnapshot.contextWindowTokens?.takeIf { it > 0 } + val explicitPercent = statsSnapshot.contextUsagePercent?.coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) + val fallbackUsedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) + val fallbackWindowTokens = currentModel?.contextWindow?.takeIf { it > 0 }?.toLong() + + val contextUsage = + buildContextUsageCoreLabel( + explicitUsedTokens = explicitUsedTokens, + explicitWindowTokens = explicitWindowTokens, + explicitPercent = explicitPercent, + fallbackUsedTokens = fallbackUsedTokens, + fallbackWindowTokens = explicitWindowTokens ?: fallbackWindowTokens, + ) + val compactionLabel = + statsSnapshot.compactionCount.takeIf { it > 0 }?.let { count -> " · C$count" }.orEmpty() + val costLabel = + statsSnapshot.totalCost.takeIf { it > 0.0 }?.let { cost -> " · ${formatCompactCost(cost)}" }.orEmpty() + + return contextUsage + compactionLabel + costLabel +} + +private fun buildContextUsageCoreLabel( + explicitUsedTokens: Long?, + explicitWindowTokens: Long?, + explicitPercent: Int?, + fallbackUsedTokens: Long, + fallbackWindowTokens: Long?, +): String { + return when { + explicitPercent != null && explicitUsedTokens != null && explicitWindowTokens != null -> + formatExactContextUsage(explicitPercent, explicitUsedTokens, explicitWindowTokens) + explicitPercent != null -> "Ctx $explicitPercent%" + explicitUsedTokens != null && explicitWindowTokens != null -> + formatExactContextUsage( + computeContextPercent(explicitUsedTokens, explicitWindowTokens), + explicitUsedTokens, + explicitWindowTokens, + ) + explicitUsedTokens != null -> "Ctx ${formatNumber(explicitUsedTokens)}" + fallbackWindowTokens != null -> + "Ctx ~${formatNumber(fallbackUsedTokens)}/${formatNumber(fallbackWindowTokens)}" + else -> "Ctx ~${formatNumber(fallbackUsedTokens)}" + } +} + +private fun computeContextPercent( + usedTokens: Long, + windowTokens: Long, +): Int { + return ((usedTokens * CONTEXT_PERCENT_FACTOR) / windowTokens.toDouble()) + .toInt() + .coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) +} + +private fun formatExactContextUsage( + percent: Int, + usedTokens: Long, + windowTokens: Long, +): String { + return "Ctx $percent% · ${formatNumber(usedTokens)}/${formatNumber(windowTokens)}" +} + +@Suppress("MagicNumber") +internal fun formatCost(value: Double): String { + return String.format(java.util.Locale.US, "$%.4f", value) +} + +@Suppress("MagicNumber") +private fun formatCompactCost(value: Double): String { + val pattern = + when { + value >= 1.0 -> "$%.2f" + value >= 0.1 -> "$%.3f" + else -> "$%.4f" + } + return String.format(java.util.Locale.US, pattern, value) +} + +private const val CONTEXT_PERCENT_FACTOR = 100.0 +private const val CONTEXT_PERCENT_MIN = 0 +private const val CONTEXT_PERCENT_MAX = 100 diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatusAndWidgets.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatusAndWidgets.kt new file mode 100644 index 0000000..2a7f08b --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatStatusAndWidgets.kt @@ -0,0 +1,467 @@ +package com.ayagmar.pimobile.ui.chat + +import androidx.compose.animation.AnimatedVisibility +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.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ayagmar.pimobile.chat.ExtensionWidget +import com.ayagmar.pimobile.sessions.ModelInfo + +@Suppress("LongMethod", "LongParameterList") +@Composable +internal fun ModelThinkingControls( + currentModel: ModelInfo?, + thinkingLevel: String?, + contextUsageLabel: String, + onSetThinkingLevel: (String) -> Unit, + onShowModelPicker: () -> Unit, + onShowStats: () -> Unit, +) { + var showThinkingMenu by remember { mutableStateOf(false) } + + val modelText = currentModel?.name ?: "Select model" + val thinkingText = thinkingLevel?.uppercase() ?: "OFF" + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AssistChip( + onClick = onShowModelPicker, + modifier = Modifier.weight(1f), + label = { + Text( + text = modelText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + + Box(modifier = Modifier.wrapContentWidth()) { + AssistChip( + onClick = { showThinkingMenu = true }, + label = { Text(thinkingText) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + + DropdownMenu( + expanded = showThinkingMenu, + onDismissRequest = { showThinkingMenu = false }, + ) { + THINKING_LEVEL_OPTIONS.forEach { level -> + DropdownMenuItem( + text = { Text(level.replaceFirstChar { it.uppercase() }) }, + onClick = { + onSetThinkingLevel(level) + showThinkingMenu = false + }, + ) + } + } + } + + AssistChip( + onClick = onShowStats, + label = { + Text( + text = contextUsageLabel.substringBefore(" ·"), + maxLines = 1, + ) + }, + ) + } +} + +@Suppress("LongMethod") +@Composable +internal fun ExtensionStatusStrip(statuses: Map) { + if (statuses.isEmpty()) return + + var expanded by rememberSaveable { mutableStateOf(false) } + var previousStatuses by remember { mutableStateOf>(emptyMap()) } + var hasPreviousSnapshot by remember { mutableStateOf(false) } + + val comparisonSnapshot = + if (hasPreviousSnapshot) { + previousStatuses + } else { + statuses.mapValues { (_, value) -> value.trim() } + } + + val presentation = + remember(statuses, comparisonSnapshot, expanded) { + buildExtensionStatusPresentation( + statuses = statuses, + previousStatuses = comparisonSnapshot, + expanded = expanded, + ) + } + + LaunchedEffect(statuses) { + previousStatuses = statuses.mapValues { (_, value) -> value.trim() } + hasPreviousSnapshot = true + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f), + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = "Extension status", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${presentation.activeCount} active · ${presentation.quietCount} quiet", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Hide" else "Show") + } + } + + if (!expanded) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val first = presentation.visibleEntries.firstOrNull() + if (first != null) { + StatusPill(entry = first) + } + if (presentation.hiddenCount > 0) { + Text( + text = "+${presentation.hiddenCount} more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + presentation.visibleEntries.forEach { entry -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (entry.isChanged) "•" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 2.dp), + ) + Text( + text = "${entry.key}: ${entry.value.take(STATUS_VALUE_MAX_LENGTH)}", + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + if (presentation.changedCount > 0) { + Text( + text = "${presentation.changedCount} update(s) since last refresh", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} + +@Composable +private fun StatusPill(entry: ExtensionStatusEntry) { + Surface( + shape = RoundedCornerShape(14.dp), + color = + if (entry.isLowSignal) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + ) { + Text( + text = "${entry.key}: ${entry.value.take(EXTENSION_STATUS_PILL_MAX_LENGTH)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +internal data class ExtensionStatusEntry( + val key: String, + val value: String, + val isLowSignal: Boolean, + val isChanged: Boolean, +) + +internal data class ExtensionStatusPresentation( + val visibleEntries: List, + val hiddenCount: Int, + val activeCount: Int, + val quietCount: Int, + val changedCount: Int, +) + +internal fun buildExtensionStatusPresentation( + statuses: Map, + previousStatuses: Map, + expanded: Boolean, +): ExtensionStatusPresentation { + if (statuses.isEmpty()) { + return ExtensionStatusPresentation( + visibleEntries = emptyList(), + hiddenCount = 0, + activeCount = 0, + quietCount = 0, + changedCount = 0, + ) + } + + val entries = + statuses + .toSortedMap() + .map { (key, rawValue) -> + val value = rawValue.trim().ifEmpty { "(empty)" } + ExtensionStatusEntry( + key = key, + value = value, + isLowSignal = isLowSignalExtensionStatus(value), + isChanged = previousStatuses[key] != value, + ) + } + + val changed = entries.filter { it.isChanged } + val active = entries.filterNot { it.isLowSignal } + val quietCount = entries.size - active.size + + val compactCandidates = + when { + changed.isNotEmpty() -> changed + active.isNotEmpty() -> active + else -> entries + } + + val visibleEntries = if (expanded) entries else compactCandidates.take(MAX_COMPACT_EXTENSION_STATUS_ITEMS) + + return ExtensionStatusPresentation( + visibleEntries = visibleEntries, + hiddenCount = if (expanded) 0 else (entries.size - visibleEntries.size).coerceAtLeast(0), + activeCount = active.size, + quietCount = quietCount, + changedCount = changed.size, + ) +} + +internal fun isLowSignalExtensionStatus(value: String): Boolean { + val normalized = value.trim().lowercase() + return LOW_SIGNAL_STATUS_TOKENS.any { token -> normalized.contains(token) } +} + +@Composable +internal fun ExtensionWidgets( + widgets: Map, + placement: String, +) { + val matchingWidgets = widgets.values.filter { it.placement == placement } + + matchingWidgets.forEach { widget -> + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier.padding(8.dp), + ) { + widget.lines.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +internal const val CHAT_PROMPT_CONTROLS_TAG = "chat_prompt_controls" +internal const val CHAT_STREAMING_CONTROLS_TAG = "chat_streaming_controls" +internal const val CHAT_PROMPT_INPUT_ROW_TAG = "chat_prompt_input_row" +internal const val CHAT_RUN_PROGRESS_TAG = "chat_run_progress" +internal const val CHAT_JUMP_TO_LATEST_TAG = "chat_jump_to_latest" + +internal const val COLLAPSED_OUTPUT_LENGTH = 280 +internal const val THINKING_COLLAPSE_THRESHOLD = 280 +internal const val MAX_ARG_DISPLAY_LENGTH = 100 +internal const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 +internal const val USER_IMAGE_PREVIEW_SIZE_DP = 56 +internal const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 +internal const val AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS = 120L +internal const val STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS = 90L +internal const val CHAT_TIMELINE_BOTTOM_ANCHOR_KEY = "chat_timeline_bottom_anchor" +internal const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 +internal const val STATUS_VALUE_MAX_LENGTH = 180 +internal const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 +internal const val MAX_COMPACT_EXTENSION_STATUS_ITEMS = 2 +internal const val RUN_PROGRESS_TICK_MS = 1_000L +internal const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" +internal val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh", "max") +internal val CODE_FENCE_REGEX = Regex("```([\\w+-]*)\\r?\\n([\\s\\S]*?)```") +internal val STRING_REGEX = Regex("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'") +internal val NUMBER_REGEX = Regex("\\b\\d+(?:\\.\\d+)?\\b") +internal val HASH_COMMENT_REGEX = Regex("#.*$", setOf(RegexOption.MULTILINE)) +internal val SLASH_COMMENT_REGEX = + Regex( + "//.*$|/\\*.*?\\*/", + setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL), + ) +internal val KOTLIN_KEYWORD_REGEX = + Regex( + "\\b(class|object|interface|fun|val|var|when|if|else|return|suspend|data|sealed|" + + "private|public|override|import|package)\\b", + ) +internal val JAVA_KEYWORD_REGEX = + Regex( + "\\b(class|interface|enum|public|private|protected|static|final|void|return|if|" + + "else|switch|case|new|import|package)\\b", + ) +internal val PYTHON_KEYWORD_REGEX = + Regex( + "\\b(def|class|import|from|as|if|elif|else|for|while|return|try|except|with|lambda|pass|break|continue)\\b", + ) +internal val JS_TS_KEYWORD_REGEX = + Regex( + "\\b(function|class|const|let|var|return|if|else|switch|case|import|from|export|async|await|interface|type)\\b", + ) +internal val BASH_KEYWORD_REGEX = Regex("\\b(if|then|fi|for|do|done|case|esac|function|export|echo)\\b") +internal val GENERIC_KEYWORD_REGEX = + Regex("\\b(if|else|for|while|return|class|function|import|from|const|let|var|def|public|private)\\b") +internal val TOOL_OUTPUT_LANGUAGE_BY_EXTENSION = + mapOf( + "kt" to "kotlin", + "kts" to "kotlin", + "java" to "java", + "js" to "javascript", + "jsx" to "javascript", + "ts" to "typescript", + "tsx" to "typescript", + "py" to "python", + "json" to "json", + "jsonl" to "json", + "xml" to "xml", + "html" to "xml", + "svg" to "xml", + "sh" to "bash", + "bash" to "bash", + "sql" to "sql", + "yml" to "yaml", + "yaml" to "yaml", + "go" to "go", + "rs" to "rust", + "md" to "markdown", + ) +internal val LOW_SIGNAL_STATUS_TOKENS = + setOf( + "idle", + "ready", + "ok", + "connected", + "none", + "no updates", + "synced", + ) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatTimeline.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatTimeline.kt new file mode 100644 index 0000000..ab7c7e9 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatTimeline.kt @@ -0,0 +1,1047 @@ +package com.ayagmar.pimobile.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil.compose.AsyncImage +import com.ayagmar.pimobile.chat.ChatTimelineItem +import dev.jeziellago.compose.markdowntext.MarkdownText +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +@Composable +internal fun ChatBody( + isLoading: Boolean, + timeline: List, + hasOlderMessages: Boolean, + hiddenHistoryCount: Int, + expandedToolArguments: Set, + isRunActive: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, + callbacks: ChatCallbacks, +) { + val hasStreamingTimelineItem = + remember(timeline) { + timeline.any { item -> + when (item) { + is ChatTimelineItem.Assistant -> item.isStreaming + is ChatTimelineItem.Tool -> item.isStreaming + is ChatTimelineItem.User -> false + } + } + } + val showInlineRunProgress = isRunActive && !hasStreamingTimelineItem + + if (isLoading) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } else if (timeline.isEmpty() && !showInlineRunProgress) { + Text( + text = "No chat messages yet. Resume a session and send a prompt.", + style = MaterialTheme.typography.bodyLarge, + ) + } else { + ChatTimeline( + timeline = timeline, + hasOlderMessages = hasOlderMessages, + hiddenHistoryCount = hiddenHistoryCount, + expandedToolArguments = expandedToolArguments, + isRunActive = isRunActive, + showInlineRunProgress = showInlineRunProgress, + runPhase = runPhase, + runElapsedSeconds = runElapsedSeconds, + onLoadOlderMessages = callbacks.onLoadOlderMessages, + onToggleToolExpansion = callbacks.onToggleToolExpansion, + onToggleThinkingExpansion = callbacks.onToggleThinkingExpansion, + onToggleDiffExpansion = callbacks.onToggleDiffExpansion, + onToggleToolArgumentsExpansion = callbacks.onToggleToolArgumentsExpansion, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ChatTimeline( + timeline: List, + hasOlderMessages: Boolean, + hiddenHistoryCount: Int, + expandedToolArguments: Set, + isRunActive: Boolean, + showInlineRunProgress: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, + onLoadOlderMessages: () -> Unit, + onToggleToolExpansion: (String) -> Unit, + onToggleThinkingExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleToolArgumentsExpansion: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var previewImageUri by rememberSaveable { mutableStateOf(null) } + val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val autoScrollUi = + rememberTimelineAutoScrollUi( + listState = listState, + timeline = timeline, + hasOlderMessages = hasOlderMessages, + showInlineRunProgress = showInlineRunProgress, + isRunActive = isRunActive, + ) + + Box(modifier = modifier.fillMaxWidth()) { + ChatTimelineList( + listState = listState, + timeline = timeline, + hasOlderMessages = hasOlderMessages, + hiddenHistoryCount = hiddenHistoryCount, + expandedToolArguments = expandedToolArguments, + showInlineRunProgress = showInlineRunProgress, + runPhase = runPhase, + runElapsedSeconds = runElapsedSeconds, + onLoadOlderMessages = onLoadOlderMessages, + onToggleToolExpansion = onToggleToolExpansion, + onToggleThinkingExpansion = onToggleThinkingExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, + onPreviewImage = { uri -> + previewImageUri = uri + }, + ) + + AnimatedVisibility( + visible = autoScrollUi.shouldShowJumpToLatest, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), + ) { + OutlinedButton( + onClick = autoScrollUi.onJumpToLatest, + modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), + ) { + Text("Jump to latest") + } + } + + previewImageUri?.let { uri -> + ImagePreviewDialog( + uriString = uri, + onDismiss = { previewImageUri = null }, + ) + } + } +} + +private data class TimelineAutoScrollUi( + val shouldShowJumpToLatest: Boolean, + val onJumpToLatest: () -> Unit, +) + +@Suppress("LongMethod") +@Composable +private fun rememberTimelineAutoScrollUi( + listState: androidx.compose.foundation.lazy.LazyListState, + timeline: List, + hasOlderMessages: Boolean, + showInlineRunProgress: Boolean, + isRunActive: Boolean, +): TimelineAutoScrollUi { + val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() + val bottomAnchorIndex = timelineBottomAnchorIndex(timeline.size, hasOlderMessages, showInlineRunProgress) + val renderedItemsCount = bottomAnchorIndex + 1 + val latestTimelineActivityKey = + remember(timeline, showInlineRunProgress) { + buildLatestTimelineActivityKey( + timeline = timeline, + showInlineRunProgress = showInlineRunProgress, + ) + } + val isNearBottom = rememberIsNearBottom(listState) + var shouldStickToBottom by + rememberShouldStickToBottom( + listState = listState, + isNearBottom = isNearBottom, + renderedItemsCount = renderedItemsCount, + ) + + val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom + + RunActivityAutoScroll( + listState = listState, + latestTimelineActivityKey = latestTimelineActivityKey, + renderedItemsCount = renderedItemsCount, + shouldAutoScrollToBottom = shouldAutoScrollToBottom, + ) + + RunStreamingAutoScroll( + listState = listState, + isRunActive = isRunActive, + shouldAutoScrollToBottom = shouldAutoScrollToBottom, + renderedItemsCount = renderedItemsCount, + ) + + return TimelineAutoScrollUi( + shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom, + onJumpToLatest = { + shouldStickToBottom = true + coroutineScope.launch { + listState.scrollToItem(bottomAnchorIndex) + } + }, + ) +} + +internal fun timelineBottomAnchorIndex( + timelineSize: Int, + hasOlderMessages: Boolean, + showInlineRunProgress: Boolean, +): Int = + timelineSize + + (if (hasOlderMessages) 1 else 0) + + (if (showInlineRunProgress) 1 else 0) + +private fun buildLatestTimelineActivityKey( + timeline: List, + showInlineRunProgress: Boolean, +): String { + val tail = timeline.lastOrNull() + val tailKey = + when (tail) { + is ChatTimelineItem.Assistant -> { + "assistant:${tail.id}:${tail.text.length}:${tail.thinking?.length ?: 0}:${tail.isStreaming}" + } + + is ChatTimelineItem.Tool -> { + "tool:${tail.id}:${tail.output.length}:${tail.isStreaming}:${tail.isCollapsed}" + } + + is ChatTimelineItem.User -> "user:${tail.id}:${tail.text.length}:${tail.imageCount}" + null -> "empty" + } + + return "$tailKey:inline=$showInlineRunProgress:count=${timeline.size}" +} + +@Composable +private fun rememberIsNearBottom(listState: androidx.compose.foundation.lazy.LazyListState): Boolean { + val isNearBottom by + remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val lastItemIndex = layoutInfo.totalItemsCount - 1 + + lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS + } + } + + return isNearBottom +} + +@Composable +private fun rememberShouldStickToBottom( + listState: androidx.compose.foundation.lazy.LazyListState, + isNearBottom: Boolean, + renderedItemsCount: Int, +): androidx.compose.runtime.MutableState { + val shouldStickToBottom = remember { mutableStateOf(true) } + + LaunchedEffect(listState.isScrollInProgress, isNearBottom, renderedItemsCount) { + if (renderedItemsCount <= 1) { + shouldStickToBottom.value = true + return@LaunchedEffect + } + + if (listState.isScrollInProgress) { + shouldStickToBottom.value = isNearBottom + } + } + + return shouldStickToBottom +} + +@Composable +private fun RunActivityAutoScroll( + listState: androidx.compose.foundation.lazy.LazyListState, + latestTimelineActivityKey: String, + renderedItemsCount: Int, + shouldAutoScrollToBottom: Boolean, +) { + var lastAutoScrollAtMs by remember { mutableStateOf(0L) } + + LaunchedEffect(latestTimelineActivityKey, renderedItemsCount, shouldAutoScrollToBottom) { + if (renderedItemsCount <= 0 || !shouldAutoScrollToBottom) { + return@LaunchedEffect + } + + val targetIndex = renderedItemsCount - 1 + val now = System.currentTimeMillis() + + when { + lastAutoScrollAtMs == 0L -> listState.scrollToItem(targetIndex) + now - lastAutoScrollAtMs >= AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS -> + listState.animateScrollToItem(targetIndex) + + else -> listState.scrollToItem(targetIndex) + } + + lastAutoScrollAtMs = now + } +} + +@Composable +private fun RunStreamingAutoScroll( + listState: androidx.compose.foundation.lazy.LazyListState, + isRunActive: Boolean, + shouldAutoScrollToBottom: Boolean, + renderedItemsCount: Int, +) { + LaunchedEffect( + isRunActive, + shouldAutoScrollToBottom, + renderedItemsCount, + listState.isScrollInProgress, + ) { + val shouldRunStreamingAutoScrollLoop = + isRunActive && + shouldAutoScrollToBottom && + renderedItemsCount > 0 && + !listState.isScrollInProgress + if (!shouldRunStreamingAutoScrollLoop) { + return@LaunchedEffect + } + + while (true) { + val targetIndex = renderedItemsCount - 1 + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + if (lastVisibleIndex < targetIndex) { + listState.scrollToItem(targetIndex) + } + delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun ChatTimelineList( + listState: androidx.compose.foundation.lazy.LazyListState, + timeline: List, + hasOlderMessages: Boolean, + hiddenHistoryCount: Int, + expandedToolArguments: Set, + showInlineRunProgress: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, + onLoadOlderMessages: () -> Unit, + onToggleToolExpansion: (String) -> Unit, + onToggleThinkingExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleToolArgumentsExpansion: (String) -> Unit, + onPreviewImage: (String) -> Unit, +) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (hasOlderMessages) { + item(key = "load-older-messages") { + TextButton( + onClick = onLoadOlderMessages, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Load older messages ($hiddenHistoryCount hidden)") + } + } + } + + items(items = timeline, key = { item -> item.id }) { item -> + ChatTimelineRow( + item = item, + expandedToolArguments = expandedToolArguments, + onToggleToolExpansion = onToggleToolExpansion, + onToggleThinkingExpansion = onToggleThinkingExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, + onPreviewImage = onPreviewImage, + ) + } + + if (showInlineRunProgress) { + item(key = "inline-run-progress") { + InlineRunProgressCard( + phase = runPhase, + elapsedSeconds = runElapsedSeconds, + ) + } + } + + item(key = CHAT_TIMELINE_BOTTOM_ANCHOR_KEY) { + Spacer(modifier = Modifier.height(1.dp)) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun ChatTimelineRow( + item: ChatTimelineItem, + expandedToolArguments: Set, + onToggleToolExpansion: (String) -> Unit, + onToggleThinkingExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleToolArgumentsExpansion: (String) -> Unit, + onPreviewImage: (String) -> Unit, +) { + when (item) { + is ChatTimelineItem.User -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + UserCard( + text = item.text, + imageCount = item.imageCount, + imageUris = item.imageUris, + onImageClick = onPreviewImage, + ) + } + } + + is ChatTimelineItem.Assistant -> { + AssistantCard( + item = item, + onToggleThinkingExpansion = onToggleThinkingExpansion, + ) + } + + is ChatTimelineItem.Tool -> { + ToolCard( + item = item, + isArgumentsExpanded = item.id in expandedToolArguments, + onToggleToolExpansion = onToggleToolExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, + ) + } + } +} + +@Suppress("LongMethod") +@Composable +private fun UserCard( + text: String, + imageCount: Int, + imageUris: List, + onImageClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.widthIn(max = 340.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "You", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = text.ifBlank { "(empty)" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + if (imageUris.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed( + items = imageUris.take(MAX_INLINE_USER_IMAGE_PREVIEWS), + key = { index, uri -> "$uri-$index" }, + ) { _, uriString -> + UserImagePreview( + uriString = uriString, + onClick = { onImageClick(uriString) }, + ) + } + + val remaining = imageUris.size - MAX_INLINE_USER_IMAGE_PREVIEWS + if (remaining > 0) { + item(key = "more-images") { + Box( + modifier = + Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text(text = "+$remaining", style = MaterialTheme.typography.labelMedium) + } + } + } + } + } + + if (imageCount > 0) { + Text( + text = if (imageCount == 1) "📎 1 image attached" else "📎 $imageCount images attached", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +@Composable +private fun UserImagePreview( + uriString: String, + onClick: () -> Unit, +) { + val uri = remember(uriString) { uriString.toUri() } + var loadFailed by remember(uriString) { mutableStateOf(false) } + + if (loadFailed) { + Box( + modifier = + Modifier + .size(USER_IMAGE_PREVIEW_SIZE_DP.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text( + text = "IMG", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + AsyncImage( + model = uri, + contentDescription = "Sent image preview", + modifier = + Modifier + .size(USER_IMAGE_PREVIEW_SIZE_DP.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentScale = ContentScale.Crop, + onError = { + loadFailed = true + }, + ) +} + +@Composable +private fun AssistantCard( + item: ChatTimelineItem.Assistant, + onToggleThinkingExpansion: (String) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val title = if (item.isStreaming) "Assistant (streaming)" else "Assistant" + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + if (item.text.isNotBlank()) { + AssistantMessageContent( + text = item.text, + modifier = Modifier.fillMaxWidth(), + ) + } + + ThinkingBlock( + thinking = item.thinking, + isThinkingComplete = item.isThinkingComplete, + isThinkingExpanded = item.isThinkingExpanded, + itemId = item.id, + onToggleThinkingExpansion = onToggleThinkingExpansion, + ) + } + } +} + +@Composable +private fun AssistantMessageContent( + text: String, + modifier: Modifier = Modifier, +) { + val blocks = remember(text) { parseAssistantMessageBlocks(text) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + blocks.forEach { block -> + when (block) { + is AssistantMessageBlock.Paragraph -> { + if (block.text.isNotBlank()) { + MarkdownText( + markdown = block.text, + style = MaterialTheme.typography.bodyMedium, + syntaxHighlightColor = MaterialTheme.colorScheme.surfaceVariant, + syntaxHighlightTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is AssistantMessageBlock.Code -> { + AssistantCodeBlock( + code = block.code, + language = block.language, + ) + } + } + } + } +} + +@Composable +private fun AssistantCodeBlock( + code: String, + language: String?, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val highlighted = highlightCodeBlock(code, language, colors) + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f), + ) { + SelectionContainer { + Text( + text = highlighted, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun ThinkingHeader(isThinkingComplete: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = if (isThinkingComplete) " Thinking" else " Thinking…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } +} + +@Composable +private fun ThinkingBlock( + thinking: String?, + isThinkingComplete: Boolean, + isThinkingExpanded: Boolean, + itemId: String, + onToggleThinkingExpansion: (String) -> Unit, +) { + if (thinking == null) return + + val thinkingStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onTertiaryContainer) + val shouldCollapse = thinking.length > THINKING_COLLAPSE_THRESHOLD + val displayThinking = + if (!isThinkingExpanded && shouldCollapse) { + thinking.take(THINKING_COLLAPSE_THRESHOLD) + "…" + } else { + thinking + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f), + ), + border = + androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ThinkingHeader(isThinkingComplete) + MarkdownText( + markdown = displayThinking, + style = thinkingStyle, + syntaxHighlightColor = MaterialTheme.colorScheme.tertiaryContainer, + syntaxHighlightTextColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + + if (shouldCollapse || isThinkingExpanded) { + TextButton( + onClick = { onToggleThinkingExpansion(itemId) }, + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + if (isThinkingExpanded) "Show less" else "Show more", + ) + } + } + } + } +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +private fun ToolCard( + item: ChatTimelineItem.Tool, + isArgumentsExpanded: Boolean, + onToggleToolExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleArgumentsExpansion: (String) -> Unit, +) { + val isEditTool = item.toolName == "edit" && item.editDiff != null + val toolInfo = getToolInfo(item.toolName) + val clipboardManager = LocalClipboardManager.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Tool header with icon + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Tool icon with color + Box( + modifier = + Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background(toolInfo.color.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = toolInfo.icon, + contentDescription = item.toolName, + tint = toolInfo.color, + modifier = Modifier.size(18.dp), + ) + } + + val suffix = + when { + item.isError -> "(error)" + item.isStreaming -> "(running)" + else -> "" + } + + Text( + text = "${item.toolName} $suffix".trim(), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + + if (item.isStreaming) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + } + + // Collapsible arguments section + if (item.arguments.isNotEmpty()) { + ToolArgumentsSection( + arguments = item.arguments, + isExpanded = isArgumentsExpanded, + onToggleExpand = { onToggleArgumentsExpansion(item.id) }, + onCopy = { + val argsJson = item.arguments.entries.joinToString("\n") { (k, v) -> "\"$k\": \"$v\"" } + clipboardManager.setText(AnnotatedString("{\n$argsJson\n}")) + }, + ) + } + + // Show diff viewer for edit tools, otherwise show standard output + if (isEditTool) { + DiffViewer( + diffInfo = item.editDiff, + isCollapsed = !item.isDiffExpanded, + onToggleCollapse = { onToggleDiffExpansion(item.id) }, + modifier = Modifier.padding(top = 8.dp), + ) + } else { + val displayOutput = + if (item.isCollapsed && item.output.length > COLLAPSED_OUTPUT_LENGTH) { + item.output.take(COLLAPSED_OUTPUT_LENGTH) + "…" + } else { + item.output + } + + val rawOutput = displayOutput.ifBlank { "(no output yet)" } + val shouldHighlight = !item.isStreaming && rawOutput.length <= TOOL_HIGHLIGHT_MAX_LENGTH + + SelectionContainer { + if (shouldHighlight) { + val inferredLanguage = inferLanguageFromToolContext(item) + val highlightedOutput = + highlightCodeBlock( + code = rawOutput, + language = inferredLanguage, + colors = MaterialTheme.colorScheme, + ) + Text( + text = highlightedOutput, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } else { + Text( + text = rawOutput, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } + + if (item.output.length > COLLAPSED_OUTPUT_LENGTH) { + TextButton(onClick = { onToggleToolExpansion(item.id) }) { + Icon( + imageVector = if (item.isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text(if (item.isCollapsed) "Expand" else "Collapse") + } + } + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun ToolArgumentsSection( + arguments: Map, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onCopy: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.clickable { onToggleExpand() }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Arguments (${arguments.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + IconButton( + onClick = onCopy, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy arguments", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isExpanded) { + SelectionContainer { + Column( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + arguments.forEach { (key, value) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = key, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + ) + Text( + text = "=", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val displayValue = + if (value.length > MAX_ARG_DISPLAY_LENGTH) { + value.take(MAX_ARG_DISPLAY_LENGTH) + "…" + } else { + value + } + Text( + text = displayValue, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } +} + +/** + * Get tool icon and color based on tool name. + */ +@Composable +private fun getToolInfo(toolName: String): ToolDisplayInfo { + val colors = MaterialTheme.colorScheme + return when (toolName) { + "read" -> ToolDisplayInfo(Icons.Default.Description, colors.primary) + "write" -> ToolDisplayInfo(Icons.Default.Edit, colors.secondary) + "edit" -> ToolDisplayInfo(Icons.Default.Edit, colors.tertiary) + "bash" -> ToolDisplayInfo(Icons.Default.Terminal, colors.error) + "grep", "rg", "find" -> ToolDisplayInfo(Icons.Default.Search, colors.primary) + "ls" -> ToolDisplayInfo(Icons.Default.Folder, colors.secondary) + else -> ToolDisplayInfo(Icons.Default.Terminal, colors.outline) + } +} + +private data class ToolDisplayInfo( + val icon: ImageVector, + val color: Color, +) + +private fun inferLanguageFromToolContext(item: ChatTimelineItem.Tool): String? { + val path = item.arguments["path"] ?: return null + val extension = path.substringAfterLast('.', missingDelimiterValue = "").lowercase() + return TOOL_OUTPUT_LANGUAGE_BY_EXTENSION[extension] +} diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/hosts/HostsScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/hosts/HostsScreen.kt index 7eb7c21..0ae85ea 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/hosts/HostsScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/hosts/HostsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -18,6 +19,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -29,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -43,12 +46,20 @@ import com.ayagmar.pimobile.hosts.HostTokenStore import com.ayagmar.pimobile.hosts.HostsUiState import com.ayagmar.pimobile.hosts.HostsViewModel import com.ayagmar.pimobile.hosts.HostsViewModelFactory +import com.ayagmar.pimobile.hosts.parseHostPairingPayload +import com.ayagmar.pimobile.hosts.toRecoveryMessage +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning @Composable fun HostsRoute( profileStore: HostProfileStore, tokenStore: HostTokenStore, diagnostics: ConnectionDiagnostics, + onHostSaved: () -> Unit = {}, ) { val factory = remember(profileStore, tokenStore, diagnostics) { @@ -62,28 +73,32 @@ fun HostsRoute( val uiState by hostsViewModel.uiState.collectAsStateWithLifecycle() var editorDraft by remember { mutableStateOf(null) } + var scanError by remember { mutableStateOf(null) } + val scanPairingCode = + rememberPairingScanner { result -> + result + .onSuccess { draft -> editorDraft = draft } + .onFailure { error -> scanError = error.message ?: "Could not read pairing QR" } + } + val actions = + HostsScreenActions( + onAddClick = { + scanError = null + editorDraft = HostDraft() + }, + onScanClick = { + scanError = null + scanPairingCode() + }, + onEditClick = { item -> editorDraft = item.toDraft() }, + onDeleteClick = hostsViewModel::deleteHost, + onTestClick = hostsViewModel::testConnection, + ) HostsScreen( state = uiState, - onAddClick = { - editorDraft = HostDraft() - }, - onEditClick = { item -> - editorDraft = - HostDraft( - id = item.profile.id, - name = item.profile.name, - host = item.profile.host, - port = item.profile.port.toString(), - useTls = item.profile.useTls, - ) - }, - onDeleteClick = { hostId -> - hostsViewModel.deleteHost(hostId) - }, - onTestClick = { hostId -> - hostsViewModel.testConnection(hostId) - }, + scanError = scanError, + actions = actions, ) val activeDraft = editorDraft @@ -94,42 +109,81 @@ fun HostsRoute( editorDraft = null }, onSave = { draft -> - hostsViewModel.saveHost(draft) - editorDraft = null + hostsViewModel.saveHost(draft) { + onHostSaved() + editorDraft = null + } }, ) } } +@Composable +private fun rememberPairingScanner(onResult: (Result) -> Unit): () -> Unit { + val context = LocalContext.current + val scanner = + remember(context) { + val options = + GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .enableAutoZoom() + .build() + GmsBarcodeScanning.getClient(context, options) + } + + return { + scanner.startScan() + .addOnSuccessListener { barcode -> + val rawValue = barcode.rawValue + val result = + if (rawValue == null) { + Result.failure(IllegalArgumentException("The QR code did not contain connection details")) + } else { + parseHostPairingPayload(rawValue) + } + onResult(result) + } + .addOnFailureListener { error -> + if (error !is ApiException || error.statusCode != CommonStatusCodes.CANCELED) { + onResult(Result.failure(IllegalStateException("Could not open the QR scanner"))) + } + } + } +} + +private fun HostProfileItem.toDraft(): HostDraft = + HostDraft( + id = profile.id, + name = profile.name, + host = profile.host, + port = profile.port.toString(), + useTls = profile.useTls, + ) + +private data class HostsScreenActions( + val onAddClick: () -> Unit, + val onScanClick: () -> Unit, + val onEditClick: (HostProfileItem) -> Unit, + val onDeleteClick: (String) -> Unit, + val onTestClick: (String) -> Unit, +) + @Composable private fun HostsScreen( state: HostsUiState, - onAddClick: () -> Unit, - onEditClick: (HostProfileItem) -> Unit, - onDeleteClick: (String) -> Unit, - onTestClick: (String) -> Unit, + scanError: String?, + actions: HostsScreenActions, ) { Column( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Hosts", - style = MaterialTheme.typography.headlineSmall, - ) - Button(onClick = onAddClick) { - Text("Add host") - } - } + HostsHeader(actions = actions) - state.errorMessage?.let { errorMessage -> + HostStateMessages(state) + scanError?.let { message -> Text( - text = errorMessage, + text = message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) @@ -146,9 +200,9 @@ private fun HostsScreen( } if (state.profiles.isEmpty()) { - Text( - text = "No hosts configured yet.", - style = MaterialTheme.typography.bodyLarge, + FirstRunConnectionCard( + onScanClick = actions.onScanClick, + onAddClick = actions.onAddClick, ) return } @@ -163,10 +217,92 @@ private fun HostsScreen( HostCard( item = item, diagnosticResult = state.diagnosticResults[item.profile.id], - onEditClick = { onEditClick(item) }, - onDeleteClick = { onDeleteClick(item.profile.id) }, - onTestClick = { onTestClick(item.profile.id) }, + onEditClick = { actions.onEditClick(item) }, + onDeleteClick = { actions.onDeleteClick(item.profile.id) }, + onTestClick = { actions.onTestClick(item.profile.id) }, + ) + } + } + } +} + +@Composable +private fun HostsHeader(actions: HostsScreenActions) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Hosts", + style = MaterialTheme.typography.headlineSmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = actions.onScanClick) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, + ) + Text("Scan QR") + } + Button(onClick = actions.onAddClick) { + Text("Add host") + } + } + } +} + +@Composable +private fun HostStateMessages(state: HostsUiState) { + if (state.requiresTokenReentry) { + Text( + text = "Token protection was updated. Re-enter a token when you next use each connection.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + state.errorMessage?.let { errorMessage -> + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun FirstRunConnectionCard( + onScanClick: () -> Unit, + onAddClick: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Connect your computer", style = MaterialTheme.typography.headlineSmall) + Text( + text = + "Before continuing, start the Pi Mobile bridge on your computer " + + "and connect both devices through Tailscale.", + style = MaterialTheme.typography.bodyMedium, + ) + Text("Run pnpm pair in the bridge folder, then scan the code shown in your terminal.") + Button( + onClick = onScanClick, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, ) + Text("Scan pairing QR") + } + TextButton( + onClick = onAddClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Enter connection manually") } } } @@ -269,11 +405,12 @@ private fun DiagnosticStatusIcon(status: DiagnosticStatus) { @Composable private fun DiagnosticResultDetail(result: DiagnosticsResult) { + val recovery = result.toRecoveryMessage() when (result) { is DiagnosticsResult.Success -> { Column { Text( - text = "Connected", + text = recovery.title, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) @@ -293,21 +430,21 @@ private fun DiagnosticResultDetail(result: DiagnosticsResult) { } is DiagnosticsResult.NetworkError -> { Text( - text = "Network: ${result.message}", + text = "${recovery.title}. ${recovery.explanation}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } is DiagnosticsResult.AuthError -> { Text( - text = "Auth: ${result.message}", + text = "${recovery.title}. ${recovery.explanation}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } is DiagnosticsResult.RpcError -> { Text( - text = "RPC: ${result.message}", + text = "${recovery.title}. ${recovery.explanation}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -326,7 +463,7 @@ private fun HostEditorDialog( AlertDialog( onDismissRequest = onDismiss, title = { - Text(if (initialDraft.id == null) "Add host" else "Edit host") + Text(if (initialDraft.id == null) "Connect your computer" else "Edit connection") }, text = { HostDraftFields( @@ -338,7 +475,7 @@ private fun HostEditorDialog( }, confirmButton = { TextButton(onClick = { onSave(draft) }) { - Text("Save") + Text(if (initialDraft.id == null) "Save connection" else "Save") } }, dismissButton = { diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt index 1d2f961..5c36415 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageManager import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -159,7 +160,7 @@ class SettingsViewModel( fun toggleAutoCompaction() { val newValue = !uiState.autoCompactionEnabled uiState = uiState.copy(autoCompactionEnabled = newValue) - prefs.edit().putBoolean(KEY_AUTO_COMPACTION, newValue).apply() + prefs.edit { putBoolean(KEY_AUTO_COMPACTION, newValue) } viewModelScope.launch { val result = sessionController.setAutoCompaction(newValue) @@ -171,7 +172,7 @@ class SettingsViewModel( autoCompactionEnabled = revertedValue, errorMessage = "Failed to update auto-compaction", ) - prefs.edit().putBoolean(KEY_AUTO_COMPACTION, revertedValue).apply() + prefs.edit { putBoolean(KEY_AUTO_COMPACTION, revertedValue) } } } } @@ -179,7 +180,7 @@ class SettingsViewModel( fun toggleAutoRetry() { val newValue = !uiState.autoRetryEnabled uiState = uiState.copy(autoRetryEnabled = newValue) - prefs.edit().putBoolean(KEY_AUTO_RETRY, newValue).apply() + prefs.edit { putBoolean(KEY_AUTO_RETRY, newValue) } viewModelScope.launch { val result = sessionController.setAutoRetry(newValue) @@ -191,7 +192,7 @@ class SettingsViewModel( autoRetryEnabled = revertedValue, errorMessage = "Failed to update auto-retry", ) - prefs.edit().putBoolean(KEY_AUTO_RETRY, revertedValue).apply() + prefs.edit { putBoolean(KEY_AUTO_RETRY, revertedValue) } } } } @@ -200,7 +201,7 @@ class SettingsViewModel( if (preference == uiState.transportPreference) return sessionController.setTransportPreference(preference) - prefs.edit().putString(KEY_TRANSPORT_PREFERENCE, preference.value).apply() + prefs.edit { putString(KEY_TRANSPORT_PREFERENCE, preference.value) } val effectiveTransport = sessionController.getEffectiveTransportPreference() uiState = @@ -214,13 +215,13 @@ class SettingsViewModel( fun setThemePreference(preference: ThemePreference) { if (preference == uiState.themePreference) return - prefs.edit().putString(KEY_THEME_PREFERENCE, preference.value).apply() + prefs.edit { putString(KEY_THEME_PREFERENCE, preference.value) } uiState = uiState.copy(themePreference = preference) } fun toggleExtensionStatusStrip() { val newValue = !uiState.showExtensionStatusStrip - prefs.edit().putBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, newValue).apply() + prefs.edit { putBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, newValue) } uiState = uiState.copy(showExtensionStatusStrip = newValue) } diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..2130cfd --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index a493837..b6c4756 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -4,10 +4,10 @@ package com.ayagmar.pimobile.chat import androidx.lifecycle.viewModelScope import com.ayagmar.pimobile.corerpc.AgentEndEvent +import com.ayagmar.pimobile.corerpc.AgentSettledEvent import com.ayagmar.pimobile.corerpc.AssistantMessageEvent import com.ayagmar.pimobile.corerpc.MessageEndEvent import com.ayagmar.pimobile.corerpc.MessageUpdateEvent -import com.ayagmar.pimobile.corerpc.TurnEndEvent import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.sessions.TreeNavigationResult import com.ayagmar.pimobile.testutil.FakeSessionController @@ -603,7 +603,7 @@ class ChatViewModelThinkingExpansionTest { } @Test - fun turnEndClearsStreamingIndicatorsAndPendingQueue() = + fun agentSettledClearsStreamingIndicatorsAndPendingQueue() = runTest(dispatcher) { val controller = FakeSessionController() val viewModel = createViewModel(controller) @@ -626,7 +626,12 @@ class ChatViewModelThinkingExpansionTest { assertTrue(viewModel.singleAssistantItem().isStreaming) assertTrue(viewModel.uiState.value.pendingQueueItems.isNotEmpty()) - controller.emitEvent(TurnEndEvent(type = "turn_end")) + controller.emitEvent(AgentEndEvent(type = "agent_end")) + dispatcher.scheduler.advanceUntilIdle() + assertTrue(viewModel.uiState.value.isStreaming) + assertTrue(viewModel.uiState.value.pendingQueueItems.isNotEmpty()) + + controller.emitEvent(AgentSettledEvent(type = "agent_settled")) dispatcher.scheduler.advanceUntilIdle() val finalState = viewModel.uiState.value diff --git a/app/src/test/java/com/ayagmar/pimobile/hosts/HostPairingPayloadTest.kt b/app/src/test/java/com/ayagmar/pimobile/hosts/HostPairingPayloadTest.kt new file mode 100644 index 0000000..e5b24fc --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/hosts/HostPairingPayloadTest.kt @@ -0,0 +1,46 @@ +package com.ayagmar.pimobile.hosts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HostPairingPayloadTest { + @Test + fun parseCreatesCompleteHostDraft() { + val result = parseHostPairingPayload(VALID_PAYLOAD) + + assertEquals( + HostDraft( + name = "workstation", + host = "workstation.example.ts.net", + port = "8787", + useTls = false, + token = "test-token", + ), + result.getOrThrow(), + ) + } + + @Test + fun parseRejectsUnrelatedQrCode() { + val result = parseHostPairingPayload("https://example.com") + + assertTrue(result.isFailure) + } + + @Test + fun parseRejectsPayloadWithoutToken() { + val result = parseHostPairingPayload(PAYLOAD_WITHOUT_TOKEN) + + assertTrue(result.isFailure) + } + + private companion object { + const val VALID_PAYLOAD = + """{"type":"pi-mobile-host","version":1,"name":"workstation",""" + + """"host":"workstation.example.ts.net","port":8787,"useTls":false,"token":"test-token"}""" + const val PAYLOAD_WITHOUT_TOKEN = + """{"type":"pi-mobile-host","version":1,"name":"workstation",""" + + """"host":"workstation.example.ts.net","port":8787,"useTls":false}""" + } +} diff --git a/app/src/test/java/com/ayagmar/pimobile/hosts/RecoveryMessageTest.kt b/app/src/test/java/com/ayagmar/pimobile/hosts/RecoveryMessageTest.kt new file mode 100644 index 0000000..8023c9e --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/hosts/RecoveryMessageTest.kt @@ -0,0 +1,37 @@ +package com.ayagmar.pimobile.hosts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class RecoveryMessageTest { + private val profile = + HostProfile( + id = "host-1", + name = "Laptop", + host = "laptop.example.ts.net", + port = 8787, + useTls = false, + ) + + @Test + fun mapsDiagnosticsToActionableRecovery() { + val network = DiagnosticsResult.NetworkError(profile, "socket refused").toRecoveryMessage() + val auth = DiagnosticsResult.AuthError(profile, "token=super-secret").toRecoveryMessage() + val rpc = DiagnosticsResult.RpcError(profile, "spawn failed").toRecoveryMessage() + + assertEquals("Try again", network.actionLabel) + assertEquals("Update token", auth.actionLabel) + assertEquals("Test Pi again", rpc.actionLabel) + } + + @Test + fun neverRendersDiagnosticTokenValues() { + val rendered = + DiagnosticsResult.AuthError(profile, "invalid token super-secret-value") + .toRecoveryMessage() + .let { message -> "${message.title} ${message.explanation} ${message.actionLabel}" } + + assertFalse(rendered.contains("super-secret-value")) + } +} diff --git a/app/src/test/java/com/ayagmar/pimobile/hosts/TokenCipherTest.kt b/app/src/test/java/com/ayagmar/pimobile/hosts/TokenCipherTest.kt new file mode 100644 index 0000000..637a9b5 --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/hosts/TokenCipherTest.kt @@ -0,0 +1,33 @@ +package com.ayagmar.pimobile.hosts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test +import javax.crypto.KeyGenerator + +class TokenCipherTest { + private val cipher = + TokenCipher( + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) + + @Test + fun encryptsWithoutPersistingPlaintextAndDecrypts() { + val token = "bridge-secret-token" + val encrypted = cipher.encrypt(token) + + assertFalse(encrypted.contains(token)) + assertEquals(token, cipher.decrypt(encrypted)) + } + + @Test + fun rejectsCorruptedCiphertext() { + val encrypted = cipher.encrypt("bridge-secret-token") + val corrupted = encrypted.dropLast(2) + "AA" + + assertThrows(Exception::class.java) { + cipher.decrypt(corrupted) + } + } +} diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index 6b70643..3d713d7 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -521,7 +521,11 @@ class RpcSessionControllerTest { functionName: String, data: JsonObject, ): T { - val method = sessionControllerKtClass.getDeclaredMethod(functionName, JsonObject::class.java) + val method = + parserClasses + .firstNotNullOfOrNull { parserClass -> + runCatching { parserClass.getDeclaredMethod(functionName, JsonObject::class.java) }.getOrNull() + } ?: error("Parser not found: $functionName") method.isAccessible = true return method.invoke(null, data) as T } @@ -552,6 +556,12 @@ class RpcSessionControllerTest { } private companion object { - val sessionControllerKtClass: Class<*> = Class.forName("com.ayagmar.pimobile.sessions.RpcSessionControllerKt") + val sessionControllerKtClass: Class<*> = + Class.forName("com.ayagmar.pimobile.sessions.RpcSessionControllerKt") + val parserClasses = + listOf( + Class.forName("com.ayagmar.pimobile.sessions.RpcSessionResponseMappersKt"), + Class.forName("com.ayagmar.pimobile.sessions.RpcAuxiliaryResponseMappersKt"), + ) } } diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/SessionEntryProjectionTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/SessionEntryProjectionTest.kt new file mode 100644 index 0000000..a2bede0 --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/SessionEntryProjectionTest.kt @@ -0,0 +1,126 @@ +package com.ayagmar.pimobile.sessions + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class SessionEntryProjectionTest { + @Test + fun appendsEntriesWithoutDuplicatingMessages() { + val projection = SessionEntryProjection() + val initial = projection.apply(entriesData(listOf(messageEntry("m1", null, "first")), "m1"), true) + val incremental = projection.apply(entriesData(listOf(messageEntry("m2", "m1", "second")), "m2"), false) + + assertEquals(listOf("first"), initial.messagesText()) + assertEquals(listOf("first", "second"), incremental.messagesText()) + } + + @Test + fun requiresRebuildWhenActiveBranchMoves() { + val projection = SessionEntryProjection() + projection.apply( + entriesData( + listOf( + messageEntry("root", null, "root"), + messageEntry("left", "root", "left"), + ), + "left", + ), + true, + ) + + val update = projection.apply(entriesData(listOf(messageEntry("right", "root", "right")), "right"), false) + + assertSame(ProjectionUpdate.RebuildRequired, update) + } + + @Test + fun requiresRebuildForUnknownEntryVariant() { + val projection = SessionEntryProjection() + val unknown = + buildJsonObject { + put("type", "future_entry") + put("id", "future-1") + put("parentId", JsonNull) + } + + assertSame( + ProjectionUpdate.RebuildRequired, + projection.apply(entriesData(listOf(unknown), "future-1"), true), + ) + } + + @Test + fun followsFirstKeptEntryDuringCompaction() { + val projection = SessionEntryProjection() + val compaction = + buildJsonObject { + put("type", "compaction") + put("id", "c1") + put("parentId", "m2") + put("timestamp", "2026-01-01T00:00:03Z") + put("summary", "earlier summary") + put("firstKeptEntryId", "m2") + put("tokensBefore", 100) + } + val update = + projection.apply( + entriesData( + listOf( + messageEntry("m1", null, "discarded"), + messageEntry("m2", "m1", "kept"), + compaction, + messageEntry("m3", "c1", "after"), + ), + "m3", + ), + true, + ) as ProjectionUpdate.Applied + + assertEquals(listOf("compactionSummary", "user", "user"), update.messages.map { it.jsonObject.string("role") }) + assertEquals(listOf("kept", "after"), update.messagesText()) + } + + private fun entriesData( + entries: List, + leafId: String, + ) = buildJsonObject { + put("entries", buildJsonArray { entries.forEach(::add) }) + put("leafId", leafId) + } + + private fun messageEntry( + id: String, + parentId: String?, + text: String, + ) = buildJsonObject { + put("type", "message") + put("id", id) + if (parentId == null) put("parentId", JsonNull) else put("parentId", parentId) + put("timestamp", "2026-01-01T00:00:00Z") + put( + "message", + buildJsonObject { + put("role", "user") + put("content", text) + put("timestamp", 1L) + }, + ) + } + + private fun ProjectionUpdate.messagesText(): List { + return (this as ProjectionUpdate.Applied).messages.mapNotNull { message -> + message.jsonObject["content"]?.jsonPrimitive?.content + } + } + + private fun kotlinx.serialization.json.JsonObject.string(name: String): String { + return getValue(name).jsonPrimitive.content + } +} diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/SessionTreeMapperTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/SessionTreeMapperTest.kt new file mode 100644 index 0000000..e75c1b5 --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/SessionTreeMapperTest.kt @@ -0,0 +1,79 @@ +package com.ayagmar.pimobile.sessions + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionTreeMapperTest { + @Test + fun mapsCanonicalRpcTreeAndResolvesFilteredLeaf() { + val snapshot = + parseRpcSessionTreeSnapshot( + data = + buildJsonObject { + put("leafId", "tool-1") + put( + "tree", + buildJsonArray { + add( + treeNode( + id = "user-1", + parentId = null, + role = "user", + text = "Start here", + children = + buildJsonArray { + add( + treeNode( + id = "tool-1", + parentId = "user-1", + role = "toolResult", + text = "tool output", + ), + ) + }, + ), + ) + }, + ) + }, + sessionPath = "/tmp/session.jsonl", + filter = "no-tools", + ) + + assertEquals("/tmp/session.jsonl", snapshot.sessionPath) + assertEquals(listOf("user-1"), snapshot.rootIds) + assertEquals("user-1", snapshot.currentLeafId) + assertEquals(listOf("user-1"), snapshot.entries.map { entry -> entry.entryId }) + assertEquals("Start here", snapshot.entries.single().preview) + } + + private fun treeNode( + id: String, + parentId: String?, + role: String, + text: String, + children: kotlinx.serialization.json.JsonArray = buildJsonArray {}, + ) = buildJsonObject { + put( + "entry", + buildJsonObject { + put("type", "message") + put("id", id) + if (parentId == null) put("parentId", JsonNull) else put("parentId", parentId) + put("timestamp", "2026-01-01T00:00:00.000Z") + put( + "message", + buildJsonObject { + put("role", role) + put("content", text) + }, + ) + }, + ) + put("children", children) + } +} diff --git a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt index 6442e57..87ea225 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -13,6 +13,7 @@ import com.ayagmar.pimobile.sessions.ForkableMessage import com.ayagmar.pimobile.sessions.ModelInfo import com.ayagmar.pimobile.sessions.SessionController import com.ayagmar.pimobile.sessions.SessionFreshnessSnapshot +import com.ayagmar.pimobile.sessions.SessionSyncMetrics import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.sessions.TransportPreference @@ -30,6 +31,8 @@ class FakeSessionController : SessionController { private val streamingState = MutableStateFlow(false) private val connectionStateFlow = MutableStateFlow(ConnectionState.DISCONNECTED) private val _sessionChanged = MutableSharedFlow(extraBufferCapacity = 16) + private val _timelineInvalidated = MutableSharedFlow(extraBufferCapacity = 16) + private val _syncMetrics = MutableStateFlow(SessionSyncMetrics()) var availableCommands: List = emptyList() var getCommandsCallCount: Int = 0 @@ -95,6 +98,8 @@ class FakeSessionController : SessionController { override val connectionState: StateFlow = connectionStateFlow override val isStreaming: StateFlow = streamingState override val sessionChanged: SharedFlow = _sessionChanged + override val timelineInvalidated: SharedFlow = _timelineInvalidated + override val syncMetrics: StateFlow = _syncMetrics suspend fun emitEvent(event: RpcIncomingMessage) { events.emit(event) @@ -116,6 +121,10 @@ class FakeSessionController : SessionController { lastTransportPreference = preference } + override fun recordSafetyPoll() { + _syncMetrics.value = _syncMetrics.value.copy(safetyPolls = _syncMetrics.value.safetyPolls + 1) + } + override fun getTransportPreference(): TransportPreference = lastTransportPreference override fun getEffectiveTransportPreference(): TransportPreference = TransportPreference.WEBSOCKET diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt index 6b19ba7..99b853a 100644 --- a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt @@ -117,15 +117,5 @@ class ChatScreenContextUsageLabelTest { private fun formatContextLabel( stats: SessionStats?, currentModel: ModelInfo?, - ): String { - val method = - Class.forName(CHAT_SCREEN_FILE_CLASS) - .getDeclaredMethod("formatContextUsageLabel", SessionStats::class.java, ModelInfo::class.java) - method.isAccessible = true - return method.invoke(null, stats, currentModel) as String - } - - companion object { - private const val CHAT_SCREEN_FILE_CLASS = "com.ayagmar.pimobile.ui.chat.ChatScreenKt" - } + ): String = formatContextUsageLabel(stats, currentModel) } diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatTimelineScrollTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatTimelineScrollTest.kt new file mode 100644 index 0000000..c4fd0eb --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatTimelineScrollTest.kt @@ -0,0 +1,30 @@ +package com.ayagmar.pimobile.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatTimelineScrollTest { + @Test + fun bottomAnchorIncludesLoadOlderAndRunProgressRows() { + assertEquals( + 12, + timelineBottomAnchorIndex( + timelineSize = 10, + hasOlderMessages = true, + showInlineRunProgress = true, + ), + ) + } + + @Test + fun bottomAnchorFollowsTimelineWithoutOptionalRows() { + assertEquals( + 10, + timelineBottomAnchorIndex( + timelineSize = 10, + hasOlderMessages = false, + showInlineRunProgress = false, + ), + ) + } +} diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index a4dfb7b..7eae5fa 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.android.test") id("org.jetbrains.kotlin.android") @@ -5,20 +7,20 @@ plugins { android { namespace = "com.ayagmar.pimobile.benchmark" - compileSdk = 34 + compileSdk = 36 compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" + kotlin { + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) } defaultConfig { minSdk = 26 - targetSdk = 34 + targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/bridge/.env.example b/bridge/.env.example new file mode 100644 index 0000000..20016d4 --- /dev/null +++ b/bridge/.env.example @@ -0,0 +1,13 @@ +# Required: generate a strong random value. Never commit the real token. +BRIDGE_AUTH_TOKEN=replace-with-random-token + +BRIDGE_HOST=127.0.0.1 +BRIDGE_PORT=8787 +BRIDGE_LOG_LEVEL=info +BRIDGE_PROCESS_IDLE_TTL_MS=300000 +BRIDGE_RECONNECT_GRACE_MS=30000 +BRIDGE_SESSION_DIR=/absolute/path/to/.pi/agent/sessions +BRIDGE_ENABLE_HEALTH_ENDPOINT=true +BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES=16777216 +BRIDGE_IMPORT_MAX_BYTES=10485760 +BRIDGE_PI_COMMAND=pi diff --git a/bridge/package.json b/bridge/package.json index cdb9fab..f5448cb 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -2,10 +2,15 @@ "name": "pi-mobile-bridge", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=22" + }, "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", + "pair": "tsx src/pair.ts", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -15,18 +20,20 @@ "dependencies": { "dotenv": "^17.3.1", "pino": "^9.9.5", - "ws": "^8.18.3" + "qrcode": "1.5.4", + "ws": "^8.21.0" }, "devDependencies": { "@eslint/js": "^9.36.0", "@types/node": "^24.6.0", + "@types/qrcode": "1.5.6", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.36.0", "globals": "^16.4.0", "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.45.0", - "vitest": "^3.2.4", - "@vitest/coverage-v8": "^3.2.4" + "vitest": "^3.2.4" } } diff --git a/bridge/pnpm-lock.yaml b/bridge/pnpm-lock.yaml index 712ef9e..83b1853 100644 --- a/bridge/pnpm-lock.yaml +++ b/bridge/pnpm-lock.yaml @@ -14,9 +14,12 @@ importers: pino: specifier: ^9.9.5 version: 9.14.0 + qrcode: + specifier: 1.5.4 + version: 1.5.4 ws: - specifier: ^8.18.3 - version: 8.19.0 + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -24,6 +27,9 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.10.13 + '@types/qrcode': + specifier: 1.5.6 + version: 1.5.6 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -348,66 +354,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -454,6 +473,9 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -614,6 +636,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -626,6 +652,9 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -649,6 +678,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -656,6 +689,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -752,6 +788,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -772,6 +812,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -875,6 +919,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -928,14 +976,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -979,6 +1039,10 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -994,6 +1058,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1001,6 +1070,13 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1022,6 +1098,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1216,6 +1295,9 @@ packages: jsdom: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1230,6 +1312,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1238,8 +1324,8 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1250,6 +1336,17 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1531,6 +1628,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.10.13 + '@types/ws@8.18.1': dependencies: '@types/node': 24.10.13 @@ -1737,6 +1838,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1752,6 +1855,12 @@ snapshots: check-error@2.1.3: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1770,10 +1879,14 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} + dijkstrajs@1.0.3: {} + dotenv@17.3.1: {} eastasianwidth@0.2.0: {} @@ -1901,6 +2014,11 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -1921,6 +2039,8 @@ snapshots: fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -2017,6 +2137,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2068,14 +2192,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -2119,6 +2253,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pngjs@5.0.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2131,10 +2267,20 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quick-format-unescaped@4.0.4: {} real-require@0.2.0: {} + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -2174,6 +2320,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2360,6 +2508,8 @@ snapshots: - tsx - yaml + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2371,6 +2521,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2383,6 +2539,27 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 - ws@8.19.0: {} + ws@8.21.0: {} + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 yocto-queue@0.1.0: {} diff --git a/bridge/src/config.ts b/bridge/src/config.ts index d830638..928aea2 100644 --- a/bridge/src/config.ts +++ b/bridge/src/config.ts @@ -9,6 +9,9 @@ const DEFAULT_LOG_LEVEL: LevelWithSilent = "info"; const DEFAULT_PROCESS_IDLE_TTL_MS = 5 * 60 * 1000; const DEFAULT_RECONNECT_GRACE_MS = 30 * 1000; const DEFAULT_SESSION_DIRECTORY = path.join(os.homedir(), ".pi", "agent", "sessions"); +const DEFAULT_WEBSOCKET_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; +const DEFAULT_IMPORT_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_PI_COMMAND = "pi"; export interface BridgeConfig { host: string; @@ -19,6 +22,9 @@ export interface BridgeConfig { reconnectGraceMs: number; sessionDirectory: string; enableHealthEndpoint: boolean; + websocketMaxPayloadBytes: number; + importMaxBytes: number; + piCommand: string; } export function parseBridgeConfig(env: NodeJS.ProcessEnv = process.env): BridgeConfig { @@ -30,6 +36,17 @@ export function parseBridgeConfig(env: NodeJS.ProcessEnv = process.env): BridgeC const reconnectGraceMs = parseReconnectGraceMs(env.BRIDGE_RECONNECT_GRACE_MS); const sessionDirectory = parseSessionDirectory(env.BRIDGE_SESSION_DIR); const enableHealthEndpoint = parseEnableHealthEndpoint(env.BRIDGE_ENABLE_HEALTH_ENDPOINT); + const websocketMaxPayloadBytes = parsePositiveInteger( + "BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES", + env.BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES, + DEFAULT_WEBSOCKET_MAX_PAYLOAD_BYTES, + ); + const importMaxBytes = parsePositiveInteger( + "BRIDGE_IMPORT_MAX_BYTES", + env.BRIDGE_IMPORT_MAX_BYTES, + DEFAULT_IMPORT_MAX_BYTES, + ); + const piCommand = env.BRIDGE_PI_COMMAND?.trim() || DEFAULT_PI_COMMAND; return { host, @@ -40,9 +57,23 @@ export function parseBridgeConfig(env: NodeJS.ProcessEnv = process.env): BridgeC reconnectGraceMs, sessionDirectory, enableHealthEndpoint, + websocketMaxPayloadBytes, + importMaxBytes, + piCommand, }; } +function parsePositiveInteger(name: string, raw: string | undefined, defaultValue: number): number { + if (!raw) return defaultValue; + + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid ${name}: ${raw}`); + } + + return value; +} + function parsePort(portRaw: string | undefined): number { if (!portRaw) return DEFAULT_PORT; diff --git a/bridge/src/pair.ts b/bridge/src/pair.ts new file mode 100644 index 0000000..46fc411 --- /dev/null +++ b/bridge/src/pair.ts @@ -0,0 +1,36 @@ +import "dotenv/config"; + +import QRCode from "qrcode"; + +import { parseBridgeConfig } from "./config.js"; +import { createHostPairingPayload } from "./pairing.js"; + +function hostArgument(args: string[]): string | undefined { + const hostIndex = args.indexOf("--host"); + if (hostIndex < 0) return undefined; + + const host = args[hostIndex + 1]?.trim(); + if (!host) throw new Error("--host requires a hostname"); + return host; +} + +async function main(): Promise { + const config = parseBridgeConfig(); + const payload = createHostPairingPayload(config, hostArgument(process.argv.slice(2))); + const qrCode = await QRCode.toString(JSON.stringify(payload), { + type: "terminal", + small: true, + errorCorrectionLevel: "M", + }); + + process.stdout.write("\nScan this code in Pi Mobile: Hosts → Scan QR\n\n"); + process.stdout.write(qrCode); + process.stdout.write(`\nHost: ${payload.host}:${payload.port}\n`); + process.stdout.write("Keep this terminal QR private because it contains the bridge token.\n\n"); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Unable to create pairing QR: ${message}\n`); + process.exitCode = 1; +}); diff --git a/bridge/src/pairing.ts b/bridge/src/pairing.ts new file mode 100644 index 0000000..b8c98c6 --- /dev/null +++ b/bridge/src/pairing.ts @@ -0,0 +1,60 @@ +import { execFileSync } from "node:child_process"; + +import type { BridgeConfig } from "./config.js"; + +export const PAIRING_PAYLOAD_TYPE = "pi-mobile-host"; +export const PAIRING_PAYLOAD_VERSION = 1; + +export interface HostPairingPayload { + type: typeof PAIRING_PAYLOAD_TYPE; + version: typeof PAIRING_PAYLOAD_VERSION; + name: string; + host: string; + port: number; + useTls: boolean; + token: string; +} + +export function createHostPairingPayload(config: BridgeConfig, hostOverride?: string): HostPairingPayload { + const host = hostOverride?.trim() || pairingHost(config.host); + const name = host.split(".")[0] || "Pi bridge"; + + return { + type: PAIRING_PAYLOAD_TYPE, + version: PAIRING_PAYLOAD_VERSION, + name, + host, + port: config.port, + useTls: false, + token: config.authToken, + }; +} + +function pairingHost(configuredHost: string): string { + if (configuredHost.endsWith(".ts.net")) return configuredHost; + + const tailscaleDnsName = readTailscaleDnsName(); + if (tailscaleDnsName) return tailscaleDnsName; + + if (configuredHost !== "0.0.0.0" && configuredHost !== "127.0.0.1" && configuredHost !== "::") { + return configuredHost; + } + + throw new Error( + "Could not determine a reachable bridge hostname. Run pnpm pair -- --host .", + ); +} + +function readTailscaleDnsName(): string | null { + try { + const raw = execFileSync("tailscale", ["status", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const status = JSON.parse(raw) as { Self?: { DNSName?: unknown } }; + const dnsName = status.Self?.DNSName; + return typeof dnsName === "string" ? dnsName.replace(/\.$/, "") : null; + } catch { + return null; + } +} diff --git a/bridge/src/server.ts b/bridge/src/server.ts index 012dd25..fdf9fea 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { access, mkdir, unlink, writeFile } from "node:fs/promises"; import http from "node:http"; @@ -33,6 +34,7 @@ export interface BridgeServer { interface BridgeServerDependencies { processManager?: PiProcessManager; sessionIndexer?: SessionIndexer; + probePiVersion?: (command: string) => Promise; } interface ClientConnectionContext { @@ -82,7 +84,28 @@ const BRIDGE_GET_SESSION_FRESHNESS_TYPE = "bridge_get_session_freshness"; const BRIDGE_SESSION_FRESHNESS_TYPE = "bridge_session_freshness"; const BRIDGE_IMPORT_SESSION_JSONL_TYPE = "bridge_import_session_jsonl"; const BRIDGE_SESSION_IMPORTED_TYPE = "bridge_session_imported"; +const BRIDGE_SESSION_INVALIDATED_TYPE = "bridge_session_invalidated"; const BRIDGE_INTERNAL_RPC_TIMEOUT_MS = 10_000; +const PI_VERSION_PROBE_TIMEOUT_MS = 3_000; + +export async function probePiVersion(command: string): Promise { + return await new Promise((resolve, reject) => { + execFile(command, ["--version"], { timeout: PI_VERSION_PROBE_TIMEOUT_MS }, (error, stdout) => { + if (error) { + reject(new Error(`Unable to run ${command} --version: ${error.message}`)); + return; + } + + const version = stdout.trim(); + if (!version) { + reject(new Error(`${command} --version returned no version`)); + return; + } + + resolve(version); + }); + }); +} export function buildPiRpcArgs(sessionDirectory: string): string[] { return [ @@ -104,7 +127,11 @@ export function createBridgeServer( ): BridgeServer { const startedAt = Date.now(); - const wsServer = new WebSocketServer({ noServer: true }); + const wsServer = new WebSocketServer({ + noServer: true, + maxPayload: config.websocketMaxPayloadBytes, + }); + const piVersionProbe = dependencies.probePiVersion ?? probePiVersion; const processManager = dependencies.processManager ?? createPiProcessManager({ idleTtlMs: config.processIdleTtlMs, @@ -112,7 +139,7 @@ export function createBridgeServer( forwarderFactory: (cwd: string) => { return createPiRpcForwarder( { - command: "pi", + command: config.piCommand, args: buildPiRpcArgs(config.sessionDirectory), cwd, }, @@ -240,6 +267,20 @@ export function createBridgeServer( clearRuntimeLeafOverridesForCwd(event.cwd); } + if (isSessionMutationEvent(event.payload)) { + const invalidationEnvelope = JSON.stringify( + createBridgeEnvelope({ + type: BRIDGE_SESSION_INVALIDATED_TYPE, + reason: sessionMutationReason(event.payload), + }), + ); + for (const [client, context] of clientContexts.entries()) { + if (client.readyState !== WsWebSocket.OPEN) continue; + if (!canReceiveRpcEvent(context, event.cwd, processManager)) continue; + client.send(invalidationEnvelope); + } + } + if (consumedByInternalWaiter) { return; } @@ -339,6 +380,9 @@ export function createBridgeServer( return { async start(): Promise { + const piVersion = await piVersionProbe(config.piCommand); + logger.info({ command: config.piCommand, version: piVersion }, "Detected Pi executable"); + await new Promise((resolve) => { server.listen(config.port, config.host, () => { resolve(); @@ -666,6 +710,19 @@ async function handleBridgeControlMessage( return; } + const contentBytes = Buffer.byteLength(content, "utf8"); + if (contentBytes > config.importMaxBytes) { + client.send( + JSON.stringify( + createBridgeErrorEnvelope( + "import_payload_too_large", + `Import exceeds the ${config.importMaxBytes} byte limit`, + ), + ), + ); + return; + } + const requestedFileName = typeof payload.fileName === "string" ? payload.fileName : undefined; try { @@ -759,6 +816,14 @@ async function handleBridgeControlMessage( ); } + client.send( + JSON.stringify( + createBridgeEnvelope({ + type: BRIDGE_SESSION_INVALIDATED_TYPE, + reason: "navigation", + }), + ), + ); client.send( JSON.stringify( createBridgeEnvelope({ @@ -908,25 +973,6 @@ async function navigateTreeUsingCommand(options: { }): Promise { const { cwd, entryId, processManager, awaitRpcEvent } = options; - const getCommandsRequestId = randomUUID(); - const getCommandsResponsePromise = awaitRpcEvent( - cwd, - (payload) => isRpcResponseForId(payload, getCommandsRequestId), - { consume: true }, - ); - - processManager.sendRpc(cwd, { - id: getCommandsRequestId, - type: "get_commands", - }); - - const getCommandsResponse = await getCommandsResponsePromise; - ensureSuccessfulRpcResponse(getCommandsResponse, "get_commands"); - - if (!hasTreeNavigationCommand(getCommandsResponse, TREE_NAVIGATION_COMMAND)) { - throw new Error("Tree navigation command is unavailable in this runtime"); - } - const navigationRequestId = randomUUID(); const operationId = randomUUID(); const statusKey = `${TREE_NAVIGATION_STATUS_PREFIX}${operationId}`; @@ -956,16 +1002,6 @@ async function navigateTreeUsingCommand(options: { return parseTreeNavigationResult(statusResponse); } -function hasTreeNavigationCommand(responsePayload: Record, commandName: string): boolean { - const data = asRecord(responsePayload.data); - const commands = Array.isArray(data?.commands) ? data.commands : []; - - return commands.some((command) => { - const commandObject = asRecord(command); - return commandObject?.name === commandName; - }); -} - function isTreeNavigationStatusEvent(payload: Record, statusKey: string): boolean { return payload.type === "extension_ui_request" && payload.method === "setStatus" && payload.statusKey === statusKey && typeof payload.statusText === "string"; @@ -1189,6 +1225,34 @@ function handleRpcEnvelope( } } +function isSessionMutationEvent(payload: Record): boolean { + if (payload.type === "message_end" || payload.type === "compaction_end") { + return true; + } + + if (payload.type !== "response" || payload.success !== true) { + return false; + } + + return typeof payload.command === "string" && [ + "prompt", + "bash", + "compact", + "fork", + "clone", + "new_session", + "switch_session", + "set_session_name", + ].includes(payload.command); +} + +function sessionMutationReason(payload: Record): string { + if (typeof payload.command === "string") { + return payload.command; + } + return typeof payload.type === "string" ? payload.type : "runtime"; +} + function restoreOrCreateContext( requestedClientId: string | undefined, disconnectedClients: Map, diff --git a/bridge/test/config.test.ts b/bridge/test/config.test.ts index ae0ec23..a3ae1f8 100644 --- a/bridge/test/config.test.ts +++ b/bridge/test/config.test.ts @@ -18,6 +18,9 @@ describe("parseBridgeConfig", () => { reconnectGraceMs: 30_000, sessionDirectory: path.join(os.homedir(), ".pi", "agent", "sessions"), enableHealthEndpoint: true, + websocketMaxPayloadBytes: 16 * 1024 * 1024, + importMaxBytes: 10 * 1024 * 1024, + piCommand: "pi", }); }); @@ -31,6 +34,9 @@ describe("parseBridgeConfig", () => { BRIDGE_RECONNECT_GRACE_MS: "12000", BRIDGE_SESSION_DIR: "./tmp/custom-sessions", BRIDGE_ENABLE_HEALTH_ENDPOINT: "false", + BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES: "2000000", + BRIDGE_IMPORT_MAX_BYTES: "1000000", + BRIDGE_PI_COMMAND: "/opt/pi/bin/pi", }); expect(config.host).toBe("100.64.0.10"); @@ -41,6 +47,9 @@ describe("parseBridgeConfig", () => { expect(config.reconnectGraceMs).toBe(12_000); expect(config.sessionDirectory).toBe(path.resolve("./tmp/custom-sessions")); expect(config.enableHealthEndpoint).toBe(false); + expect(config.websocketMaxPayloadBytes).toBe(2_000_000); + expect(config.importMaxBytes).toBe(1_000_000); + expect(config.piCommand).toBe("/opt/pi/bin/pi"); }); it("fails on invalid port", () => { @@ -65,6 +74,15 @@ describe("parseBridgeConfig", () => { ).toThrow("Invalid BRIDGE_RECONNECT_GRACE_MS: -1"); }); + it.each([ + ["BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES", "0"], + ["BRIDGE_IMPORT_MAX_BYTES", "1.5"], + ])("fails when %s is invalid", (name, value) => { + expect(() => parseBridgeConfig({ BRIDGE_AUTH_TOKEN: "test-token", [name]: value })).toThrow( + `Invalid ${name}: ${value}`, + ); + }); + it("fails when health endpoint flag is invalid", () => { expect(() => parseBridgeConfig({ BRIDGE_AUTH_TOKEN: "test-token", BRIDGE_ENABLE_HEALTH_ENDPOINT: "maybe" }), diff --git a/bridge/test/pairing.test.ts b/bridge/test/pairing.test.ts new file mode 100644 index 0000000..4c7f7ef --- /dev/null +++ b/bridge/test/pairing.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import type { BridgeConfig } from "../src/config.js"; +import { createHostPairingPayload } from "../src/pairing.js"; + +describe("host pairing payload", () => { + it("encodes the existing bridge connection for mobile import", () => { + const config: BridgeConfig = { + host: "127.0.0.1", + port: 8787, + logLevel: "info", + authToken: "local-test-token", + processIdleTtlMs: 300_000, + reconnectGraceMs: 30_000, + sessionDirectory: "/tmp/sessions", + enableHealthEndpoint: true, + websocketMaxPayloadBytes: 16_777_216, + importMaxBytes: 10_485_760, + piCommand: "pi", + }; + + expect(createHostPairingPayload(config, "workstation.example.ts.net")).toEqual({ + type: "pi-mobile-host", + version: 1, + name: "workstation", + host: "workstation.example.ts.net", + port: 8787, + useTls: false, + token: "local-test-token", + }); + }); +}); diff --git a/bridge/test/server.test.ts b/bridge/test/server.test.ts index 986f967..600a850 100644 --- a/bridge/test/server.test.ts +++ b/bridge/test/server.test.ts @@ -473,8 +473,7 @@ describe("bridge websocket server", () => { expect(navigationEnvelope.payload?.sessionPath).toBe("/tmp/session-tree.jsonl"); const sentCommandTypes = fakeProcessManager.sentPayloads.map((entry) => entry.payload.type); - expect(sentCommandTypes).toContain("get_commands"); - expect(sentCommandTypes).toContain("prompt"); + expect(sentCommandTypes).toEqual(["prompt"]); const waitForTree = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_session_tree"); ws.send( @@ -595,65 +594,6 @@ describe("bridge websocket server", () => { ws.close(); }); - it("returns bridge_error when tree navigation command is unavailable", async () => { - const fakeProcessManager = new FakeProcessManager(); - fakeProcessManager.availableCommandNames = []; - - const { baseUrl, server } = await startBridgeServer({ - processManager: fakeProcessManager, - }); - bridgeServer = server; - - const ws = await connectWebSocket(baseUrl, { - headers: { - authorization: "Bearer bridge-token", - }, - }); - - const waitForCwdSet = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_cwd_set"); - ws.send( - JSON.stringify({ - channel: "bridge", - payload: { - type: "bridge_set_cwd", - cwd: "/tmp/project", - }, - }), - ); - await waitForCwdSet; - - const waitForControl = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_control_acquired"); - ws.send( - JSON.stringify({ - channel: "bridge", - payload: { - type: "bridge_acquire_control", - cwd: "/tmp/project", - }, - }), - ); - await waitForControl; - - const waitForError = waitForEnvelope(ws, (envelope) => { - return envelope.payload?.type === "bridge_error" && envelope.payload?.code === "tree_navigation_failed"; - }); - - ws.send( - JSON.stringify({ - channel: "bridge", - payload: { - type: "bridge_navigate_tree", - entryId: "entry-42", - }, - }), - ); - - const errorEnvelope = await waitForError; - expect(errorEnvelope.payload?.message).toContain("unavailable"); - - ws.close(); - }); - it("returns bridge_error for invalid bridge_get_session_tree filter", async () => { const { baseUrl, server } = await startBridgeServer(); bridgeServer = server; @@ -779,6 +719,69 @@ describe("bridge websocket server", () => { ws.close(); }); + it("pushes session invalidation for mutations observed through pi", async () => { + const fakeProcessManager = new FakeProcessManager(); + const { baseUrl, server } = await startBridgeServer({ processManager: fakeProcessManager }); + bridgeServer = server; + + const ws = await connectWebSocket(baseUrl, { + headers: { authorization: "Bearer bridge-token" }, + }); + + const waitForCwdSet = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_cwd_set"); + ws.send(JSON.stringify({ + channel: "bridge", + payload: { type: "bridge_set_cwd", cwd: "/tmp/project-invalidation" }, + })); + await waitForCwdSet; + + const waitForControl = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_control_acquired"); + ws.send(JSON.stringify({ + channel: "bridge", + payload: { type: "bridge_acquire_control", cwd: "/tmp/project-invalidation" }, + })); + await waitForControl; + + const waitForInvalidation = waitForEnvelope( + ws, + (envelope) => envelope.payload?.type === "bridge_session_invalidated", + ); + fakeProcessManager.emitRpcEvent("/tmp/project-invalidation", { + type: "message_end", + message: { role: "assistant", content: [] }, + }); + + const invalidation = await waitForInvalidation; + expect(invalidation.payload?.reason).toBe("message_end"); + + ws.close(); + }); + + it("does not push session invalidation for read-only rpc responses", async () => { + const fakeProcessManager = new FakeProcessManager(); + const { baseUrl, server } = await startBridgeServer({ processManager: fakeProcessManager }); + bridgeServer = server; + + const ws = await connectWebSocket(baseUrl, { + headers: { authorization: "Bearer bridge-token" }, + }); + const receivedTypes: string[] = []; + ws.on("message", (data) => { + const envelope = JSON.parse(data.toString()) as { payload?: { type?: string } }; + if (envelope.payload?.type) receivedTypes.push(envelope.payload.type); + }); + + fakeProcessManager.emitRpcEvent("/tmp/uncontrolled", { + type: "response", + command: "get_entries", + success: true, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(receivedTypes).not.toContain("bridge_session_invalidated"); + ws.close(); + }); + it("normalizes cwd paths for set_cwd, acquire_control, and rpc forwarding", async () => { const fakeProcessManager = new FakeProcessManager(); const { baseUrl, server } = await startBridgeServer({ processManager: fakeProcessManager }); @@ -1228,9 +1231,12 @@ describe("bridge websocket server", () => { reconnectGraceMs: 100, sessionDirectory, enableHealthEndpoint: true, + websocketMaxPayloadBytes: 16 * 1024 * 1024, + importMaxBytes: 10 * 1024 * 1024, + piCommand: "pi", }, logger, - { processManager: fakeProcessManager }, + { processManager: fakeProcessManager, probePiVersion: async () => "0.80.6" }, ); bridgeServer = server; @@ -1308,9 +1314,12 @@ describe("bridge websocket server", () => { reconnectGraceMs: 100, sessionDirectory: "/tmp/pi-sessions", enableHealthEndpoint: false, + websocketMaxPayloadBytes: 16 * 1024 * 1024, + importMaxBytes: 10 * 1024 * 1024, + piCommand: "pi", }, logger, - { processManager: fakeProcessManager }, + { processManager: fakeProcessManager, probePiVersion: async () => "0.80.6" }, ); bridgeServer = server; @@ -1366,9 +1375,12 @@ async function startBridgeServer( reconnectGraceMs: 100, sessionDirectory: "/tmp/pi-sessions", enableHealthEndpoint: true, + websocketMaxPayloadBytes: 16 * 1024 * 1024, + importMaxBytes: 10 * 1024 * 1024, + piCommand: "pi", }, logger, - deps, + { ...deps, probePiVersion: async () => "0.80.6" }, ); const serverInfo = await server.start(); @@ -1564,7 +1576,6 @@ class FakeSessionIndexer implements SessionIndexer { class FakeProcessManager implements PiProcessManager { sentPayloads: Array<{ cwd: string; payload: Record }> = []; - availableCommandNames: string[] = ["pi-mobile-tree"]; treeNavigationResult = { cancelled: false, editorText: "Retry with additional assertions", @@ -1592,25 +1603,6 @@ class FakeProcessManager implements PiProcessManager { sendRpc(cwd: string, payload: Record): void { this.sentPayloads.push({ cwd, payload }); - if (payload.type === "get_commands") { - this.messageHandler({ - cwd, - payload: { - id: payload.id, - type: "response", - command: "get_commands", - success: true, - data: { - commands: this.availableCommandNames.map((name) => ({ - name, - source: "extension", - })), - }, - }, - }); - return; - } - if (payload.type === "prompt" && typeof payload.message === "string" && payload.message.startsWith("/pi-mobile-tree ")) { const statusKey = payload.message.split(/\s+/)[2]; diff --git a/build.gradle.kts b/build.gradle.kts index 1cc90b3..173267a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,12 +3,13 @@ import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.jlleitschuh.gradle.ktlint.KtlintExtension plugins { - id("com.android.application") version "8.5.2" apply false - id("com.android.library") version "8.5.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.24" apply false - id("org.jetbrains.kotlin.jvm") version "1.9.24" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false - id("org.jetbrains.kotlinx.kover") version "0.8.3" apply false + id("com.android.application") version "8.13.2" apply false + id("com.android.library") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("org.jetbrains.kotlin.jvm") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false + id("org.jetbrains.kotlinx.kover") version "0.9.8" apply false id("org.jlleitschuh.gradle.ktlint") version "12.1.1" apply false id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false } diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt index 65e9496..a717770 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt @@ -1,7 +1,9 @@ package com.ayagmar.pimobile.corenet +import com.ayagmar.pimobile.corerpc.GetEntriesCommand import com.ayagmar.pimobile.corerpc.GetMessagesCommand import com.ayagmar.pimobile.corerpc.GetStateCommand +import com.ayagmar.pimobile.corerpc.GetTreeCommand import com.ayagmar.pimobile.corerpc.RpcCommand import com.ayagmar.pimobile.corerpc.RpcIncomingMessage import com.ayagmar.pimobile.corerpc.RpcMessageParser @@ -27,6 +29,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -43,6 +46,7 @@ class PiRpcConnection( ) { private val lifecycleMutex = Mutex() private val reconnectSyncMutex = Mutex() + private val entrySyncMutex = Mutex() private val pendingResponses = ConcurrentHashMap>() private val bridgeChannels = ConcurrentHashMap>() @@ -53,6 +57,8 @@ class PiRpcConnection( private var inboundJob: Job? = null private var connectionMonitorJob: Job? = null private var activeConfig: PiRpcConnectionConfig? = null + private var entriesCursor: String? = null + private var cursorSessionPath: String? = null @Volatile private var lifecycleEpoch: Long = 0 @@ -66,6 +72,10 @@ class PiRpcConnection( val resolvedConfig = config.resolveClientId() val connectionEpoch = lifecycleMutex.withLock { + if (activeConfig?.sessionPath != resolvedConfig.sessionPath) { + entriesCursor = null + cursorSessionPath = resolvedConfig.sessionPath + } activeConfig = resolvedConfig lifecycleEpoch += 1 startBackgroundJobs() @@ -128,6 +138,8 @@ class PiRpcConnection( lifecycleMutex.withLock { val currentConfig = activeConfig activeConfig = null + entriesCursor = null + cursorSessionPath = null lifecycleEpoch += 1 inboundJob?.cancel() connectionMonitorJob?.cancel() @@ -190,6 +202,14 @@ class PiRpcConnection( return requestResponse(GetMessagesCommand(id = requestIdFactory())) } + suspend fun requestEntries(since: String? = null): RpcResponse { + return requestResponse(GetEntriesCommand(id = requestIdFactory(), since = since)) + } + + suspend fun requestTree(): RpcResponse { + return requestResponse(GetTreeCommand(id = requestIdFactory())) + } + suspend fun resync(): RpcResyncSnapshot { val snapshot = buildResyncSnapshot() _resyncEvents.emit(snapshot) @@ -295,12 +315,34 @@ class PiRpcConnection( } } - private suspend fun buildResyncSnapshot(): RpcResyncSnapshot { + private suspend fun buildResyncSnapshot(): RpcResyncSnapshot = + entrySyncMutex.withLock { + buildResyncSnapshotLocked() + } + + private suspend fun buildResyncSnapshotLocked(): RpcResyncSnapshot { val stateResponse = requestState() - val messagesResponse = requestMessages() + val sessionPath = stateResponse.data?.stringField("sessionFile") + if (cursorSessionPath != sessionPath) { + entriesCursor = null + cursorSessionPath = sessionPath + } + + val requestedCursor = entriesCursor + var entriesResponse = requestEntries(requestedCursor) + var fullRebuild = requestedCursor == null + if (!entriesResponse.success && requestedCursor != null) { + entriesCursor = null + entriesResponse = requestEntries() + fullRebuild = true + } + entriesResponse.requireSuccessfulEntriesResponse() + entriesCursor = entriesResponse.lastEntryId() ?: entriesCursor + return RpcResyncSnapshot( stateResponse = stateResponse, - messagesResponse = messagesResponse, + entriesResponse = entriesResponse, + fullRebuild = fullRebuild, ) } @@ -411,7 +453,8 @@ data class BridgeMessage( data class RpcResyncSnapshot( val stateResponse: RpcResponse, - val messagesResponse: RpcResponse, + val entriesResponse: RpcResponse, + val fullRebuild: Boolean, ) private suspend fun ensureBridgeControl( @@ -530,6 +573,16 @@ private fun JsonObject.stringField(name: String): String? { return primitive.contentOrNull } +private fun RpcResponse.requireSuccessfulEntriesResponse() { + check(success) { error ?: "Failed to synchronize session entries" } + check(command == "get_entries") { "Expected get_entries response, received $command" } +} + +private fun RpcResponse.lastEntryId(): String? { + val entries = runCatching { data?.get("entries")?.jsonArray }.getOrNull() ?: return null + return entries.lastOrNull()?.jsonObject?.stringField("id") +} + private fun bridgeChannel( channels: ConcurrentHashMap>, type: String, diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncoding.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncoding.kt index 2ed0529..b07d5a3 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncoding.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncoding.kt @@ -4,6 +4,7 @@ import com.ayagmar.pimobile.corerpc.AbortBashCommand import com.ayagmar.pimobile.corerpc.AbortCommand import com.ayagmar.pimobile.corerpc.AbortRetryCommand import com.ayagmar.pimobile.corerpc.BashCommand +import com.ayagmar.pimobile.corerpc.CloneCommand import com.ayagmar.pimobile.corerpc.CompactCommand import com.ayagmar.pimobile.corerpc.CycleModelCommand import com.ayagmar.pimobile.corerpc.CycleThinkingLevelCommand @@ -13,11 +14,13 @@ import com.ayagmar.pimobile.corerpc.FollowUpCommand import com.ayagmar.pimobile.corerpc.ForkCommand import com.ayagmar.pimobile.corerpc.GetAvailableModelsCommand import com.ayagmar.pimobile.corerpc.GetCommandsCommand +import com.ayagmar.pimobile.corerpc.GetEntriesCommand import com.ayagmar.pimobile.corerpc.GetForkMessagesCommand import com.ayagmar.pimobile.corerpc.GetLastAssistantTextCommand import com.ayagmar.pimobile.corerpc.GetMessagesCommand import com.ayagmar.pimobile.corerpc.GetSessionStatsCommand import com.ayagmar.pimobile.corerpc.GetStateCommand +import com.ayagmar.pimobile.corerpc.GetTreeCommand import com.ayagmar.pimobile.corerpc.NewSessionCommand import com.ayagmar.pimobile.corerpc.PromptCommand import com.ayagmar.pimobile.corerpc.RpcCommand @@ -48,10 +51,13 @@ private val rpcCommandEncoders: Map, RpcCommandEncoder> = AbortRetryCommand::class.java to typedEncoder(AbortRetryCommand.serializer()), GetStateCommand::class.java to typedEncoder(GetStateCommand.serializer()), GetMessagesCommand::class.java to typedEncoder(GetMessagesCommand.serializer()), + GetEntriesCommand::class.java to typedEncoder(GetEntriesCommand.serializer()), + GetTreeCommand::class.java to typedEncoder(GetTreeCommand.serializer()), SwitchSessionCommand::class.java to typedEncoder(SwitchSessionCommand.serializer()), SetSessionNameCommand::class.java to typedEncoder(SetSessionNameCommand.serializer()), GetForkMessagesCommand::class.java to typedEncoder(GetForkMessagesCommand.serializer()), ForkCommand::class.java to typedEncoder(ForkCommand.serializer()), + CloneCommand::class.java to typedEncoder(CloneCommand.serializer()), ExportHtmlCommand::class.java to typedEncoder(ExportHtmlCommand.serializer()), CompactCommand::class.java to typedEncoder(CompactCommand.serializer()), CycleModelCommand::class.java to typedEncoder(CycleModelCommand.serializer()), diff --git a/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnectionTest.kt b/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnectionTest.kt index d51e8be..691e3a1 100644 --- a/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnectionTest.kt +++ b/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnectionTest.kt @@ -37,14 +37,14 @@ class PiRpcConnectionTest { val sentTypes = transport.sentPayloadTypes() assertEquals( - listOf("bridge_set_cwd", "bridge_acquire_control", "get_state", "get_messages"), + listOf("bridge_set_cwd", "bridge_acquire_control", "get_state", "get_entries"), sentTypes, ) assertTrue(transport.connectedTarget?.url?.contains("clientId=client-a") == true) val snapshot = connection.resyncEvents.first() assertEquals("get_state", snapshot.stateResponse.command) - assertEquals("get_messages", snapshot.messagesResponse.command) + assertEquals("get_entries", snapshot.entriesResponse.command) connection.disconnect() } @@ -133,11 +133,40 @@ class PiRpcConnectionTest { val reconnectSnapshot = withTimeout(2_000) { reconnectSnapshotDeferred.await() } assertEquals("get_state", reconnectSnapshot.stateResponse.command) - assertEquals("get_messages", reconnectSnapshot.messagesResponse.command) + assertEquals("get_entries", reconnectSnapshot.entriesResponse.command) assertEquals( - listOf("bridge_set_cwd", "bridge_acquire_control", "get_state", "get_messages"), + listOf("bridge_set_cwd", "bridge_acquire_control", "get_state", "get_entries"), transport.sentPayloadTypes(), ) + val entriesRequest = transport.sentPayloads().last() + assertEquals("entry-1", entriesRequest["since"]?.toString()?.trim('"')) + + connection.disconnect() + } + + @Test + fun `unknown entry cursor performs exactly one full entries rebuild`() = + runBlocking { + val transport = FakeSocketTransport() + transport.onSend = { outgoing -> transport.respondToOutgoing(outgoing) } + val connection = PiRpcConnection(transport = transport) + + connection.connect( + PiRpcConnectionConfig( + target = WebSocketTarget(url = "ws://127.0.0.1:3000/ws"), + cwd = "/tmp/project-cursor", + clientId = "client-cursor", + ), + ) + + transport.clearSentMessages() + transport.rejectNextEntriesCursor = true + val snapshot = connection.resync() + + assertTrue(snapshot.fullRebuild) + assertEquals(listOf("get_state", "get_entries", "get_entries"), transport.sentPayloadTypes()) + assertEquals("entry-1", transport.sentPayloads()[1]["since"]?.toString()?.trim('"')) + assertTrue("since" !in transport.sentPayloads()[2]) connection.disconnect() } @@ -185,6 +214,7 @@ class PiRpcConnectionTest { val sentMessages = mutableListOf() var connectedTarget: WebSocketTarget? = null var onSend: suspend (String) -> Unit = {} + var rejectNextEntriesCursor = false override suspend fun connect(target: WebSocketTarget) { connectedTarget = target @@ -282,6 +312,52 @@ class PiRpcConnectionTest { command = "get_messages", ) } + if (channel == "rpc" && type == "get_entries") { + respondToEntries(payload) + } + } + + private suspend fun respondToEntries(payload: JsonObject) { + if (rejectNextEntriesCursor && "since" in payload) { + rejectNextEntriesCursor = false + emitRpc( + buildJsonObject { + put("id", payload["id"]?.toString()?.trim('"').orEmpty()) + put("type", "response") + put("command", "get_entries") + put("success", false) + put("error", "Unknown entry cursor") + }, + ) + return + } + + emitRpc( + buildJsonObject { + put("id", payload["id"]?.toString()?.trim('"').orEmpty()) + put("type", "response") + put("command", "get_entries") + put("success", true) + put( + "data", + buildJsonObject { + put( + "entries", + kotlinx.serialization.json.buildJsonArray { + add( + buildJsonObject { + put("type", "message") + put("id", "entry-1") + put("parentId", JsonNull) + }, + ) + }, + ) + put("leafId", "entry-1") + }, + ) + }, + ) } suspend fun respondToBridgeControlOnly(message: String) { diff --git a/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncodingTest.kt b/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncodingTest.kt index 6a7478e..3333e97 100644 --- a/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncodingTest.kt +++ b/core-net/src/test/kotlin/com/ayagmar/pimobile/corenet/RpcCommandEncodingTest.kt @@ -1,9 +1,12 @@ package com.ayagmar.pimobile.corenet import com.ayagmar.pimobile.corerpc.AbortRetryCommand +import com.ayagmar.pimobile.corerpc.CloneCommand import com.ayagmar.pimobile.corerpc.CycleModelCommand import com.ayagmar.pimobile.corerpc.CycleThinkingLevelCommand +import com.ayagmar.pimobile.corerpc.GetEntriesCommand import com.ayagmar.pimobile.corerpc.GetLastAssistantTextCommand +import com.ayagmar.pimobile.corerpc.GetTreeCommand import com.ayagmar.pimobile.corerpc.NewSessionCommand import com.ayagmar.pimobile.corerpc.SetFollowUpModeCommand import com.ayagmar.pimobile.corerpc.SetSteeringModeCommand @@ -14,6 +17,18 @@ import kotlin.test.Test import kotlin.test.assertEquals class RpcCommandEncodingTest { + @Test + fun `encodes current session topology commands`() { + val entries = encodeRpcCommand(Json, GetEntriesCommand(id = "entries-1", since = "entry-1")) + val tree = encodeRpcCommand(Json, GetTreeCommand(id = "tree-1")) + val clone = encodeRpcCommand(Json, CloneCommand(id = "clone-1")) + + assertEquals("get_entries", entries["type"]?.jsonPrimitive?.content) + assertEquals("entry-1", entries["since"]?.jsonPrimitive?.content) + assertEquals("get_tree", tree["type"]?.jsonPrimitive?.content) + assertEquals("clone", clone["type"]?.jsonPrimitive?.content) + } + @Test fun `encodes cycle model command`() { val encoded = encodeRpcCommand(Json, CycleModelCommand(id = "cycle-1")) diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt index e193abe..c5210b4 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt @@ -53,6 +53,19 @@ data class GetMessagesCommand( override val type: String = "get_messages", ) : RpcCommand +@Serializable +data class GetEntriesCommand( + override val id: String? = null, + override val type: String = "get_entries", + val since: String? = null, +) : RpcCommand + +@Serializable +data class GetTreeCommand( + override val id: String? = null, + override val type: String = "get_tree", +) : RpcCommand + @Serializable data class SwitchSessionCommand( override val id: String? = null, @@ -80,6 +93,12 @@ data class ForkCommand( val entryId: String, ) : RpcCommand +@Serializable +data class CloneCommand( + override val id: String? = null, + override val type: String = "clone", +) : RpcCommand + @Serializable data class ExportHtmlCommand( override val id: String? = null, diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcIncomingMessage.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcIncomingMessage.kt index 2d24bd0..b5477d2 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcIncomingMessage.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcIncomingMessage.kt @@ -122,6 +122,12 @@ data class AgentStartEvent( data class AgentEndEvent( override val type: String, val messages: List? = null, + val willRetry: Boolean = false, +) : RpcEvent + +@Serializable +data class AgentSettledEvent( + override val type: String, ) : RpcEvent @Serializable diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParser.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParser.kt index 7f59bfd..b62686c 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParser.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParser.kt @@ -29,10 +29,15 @@ class RpcMessageParser( "extension_error" -> json.decodeFromJsonElement(jsonObject) "agent_start" -> json.decodeFromJsonElement(jsonObject) "agent_end" -> json.decodeFromJsonElement(jsonObject) + "agent_settled" -> json.decodeFromJsonElement(jsonObject) "turn_start" -> json.decodeFromJsonElement(jsonObject) "turn_end" -> json.decodeFromJsonElement(jsonObject) - "auto_compaction_start" -> json.decodeFromJsonElement(jsonObject) - "auto_compaction_end" -> json.decodeFromJsonElement(jsonObject) + "compaction_start", + "auto_compaction_start", + -> json.decodeFromJsonElement(jsonObject) + "compaction_end", + "auto_compaction_end", + -> json.decodeFromJsonElement(jsonObject) "auto_retry_start" -> json.decodeFromJsonElement(jsonObject) "auto_retry_end" -> json.decodeFromJsonElement(jsonObject) else -> GenericRpcEvent(type = type, payload = jsonObject) diff --git a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt index 39fbf10..c0c6b26 100644 --- a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt +++ b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt @@ -11,6 +11,27 @@ import kotlin.test.assertTrue class RpcMessageParserTest { private val parser = RpcMessageParser() + @Test + fun `parse all sanitized conformance fixtures`() { + val fixtureNames = listOf("lifecycle.jsonl", "session-commands.jsonl") + + fixtureNames.forEach { fixtureName -> + val resource = checkNotNull(javaClass.getResourceAsStream("/rpc/$fixtureName")) + resource.bufferedReader().useLines { lines -> + lines.filter { it.isNotBlank() }.forEach { parser.parse(it) } + } + } + } + + @Test + fun `parse agent settled event and retry hint`() { + val end = assertIs(parser.parse("""{"type":"agent_end","willRetry":true}""")) + val settled = assertIs(parser.parse("""{"type":"agent_settled"}""")) + + assertTrue(end.willRetry) + assertEquals("agent_settled", settled.type) + } + @Test fun `parse response success`() { val line = diff --git a/core-rpc/src/test/resources/rpc/lifecycle.jsonl b/core-rpc/src/test/resources/rpc/lifecycle.jsonl new file mode 100644 index 0000000..fb5352a --- /dev/null +++ b/core-rpc/src/test/resources/rpc/lifecycle.jsonl @@ -0,0 +1,5 @@ +{"type":"agent_start","futureField":"ignored"} +{"type":"agent_end","messages":[],"willRetry":true,"futureField":"ignored"} +{"type":"agent_settled","futureField":"ignored"} +{"type":"compaction_start","reason":"overflow"} +{"type":"compaction_end","reason":"overflow","result":null,"aborted":false,"willRetry":false,"errorMessage":"quota"} diff --git a/core-rpc/src/test/resources/rpc/session-commands.jsonl b/core-rpc/src/test/resources/rpc/session-commands.jsonl new file mode 100644 index 0000000..b0afd63 --- /dev/null +++ b/core-rpc/src/test/resources/rpc/session-commands.jsonl @@ -0,0 +1,3 @@ +{"id":"entries-1","type":"response","command":"get_entries","success":true,"data":{"entries":[],"leafId":null},"futureField":"ignored"} +{"id":"tree-1","type":"response","command":"get_tree","success":true,"data":{"tree":[],"leafId":null}} +{"id":"clone-1","type":"response","command":"clone","success":true,"data":{"cancelled":false}} diff --git a/docs/README.md b/docs/README.md index d0312ba..6242d6d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,12 +11,18 @@ This directory contains human-facing documentation for the Pi Mobile app and bri - [Custom Extensions](extensions.md) - [Bridge Protocol Reference](bridge-protocol.md) - [Testing](testing.md) +- [Dependency Matrix](dependency-matrix.md) +- [Release Verification](release.md) +- [Revival Acceptance](revival-acceptance.md) +- [Onboarding and Recovery](onboarding.md) - [Performance Baseline](perf-baseline.md) -- [Final Acceptance Report](final-acceptance.md) +- [Historical Final Acceptance Report](final-acceptance.md) +- [Current Pi RPC Gap Assessment](pi-upstream-opportunities.md) ## Notes -- `docs/ai/` contains planning/progress notes generated during implementation. +- The top-level documents listed above are maintained documentation. +- `docs/ai/` and `docs/spikes/` contain historical planning, progress, and investigation artifacts. They may describe superseded behavior and are not current implementation instructions. - For day-to-day development and maintenance, start with: 1. [Product Overview, Demo, and Screenshots (README)](../README.md) 2. [Architecture (Mermaid diagrams)](architecture.md) diff --git a/docs/adr/ADR-0001-bridge-required.md b/docs/adr/ADR-0001-bridge-required.md index 90e52cf..284950e 100644 --- a/docs/adr/ADR-0001-bridge-required.md +++ b/docs/adr/ADR-0001-bridge-required.md @@ -31,7 +31,7 @@ Introduce a Node.js bridge as the network-facing endpoint. - Clean separation of mobile control plane from raw RPC stream. - Central place for auth, process lifecycle, and protocol validation. -- Enables features unavailable in raw RPC (session list/tree/freshness from JSONL files). +- Enables features unavailable in raw RPC (cross-project session listing, network policy, and external-file freshness checks). Current Pi RPC provides tree reads. - Keeps Android code simpler and transport-focused. ### Negative diff --git a/docs/adr/ADR-0003-reconnect-resync-and-freshness.md b/docs/adr/ADR-0003-reconnect-resync-and-freshness.md index c823268..8afc7b7 100644 --- a/docs/adr/ADR-0003-reconnect-resync-and-freshness.md +++ b/docs/adr/ADR-0003-reconnect-resync-and-freshness.md @@ -20,15 +20,15 @@ Use a two-part consistency strategy: 1. **Reconnect + deterministic resync** - transport reconnects with backoff - on reconnect, client waits for `bridge_hello`, reapplies cwd/control - - then fetches fresh `get_state` + `get_messages` - - emits a resync snapshot consumed by ViewModels + - then fetches `get_state` + `get_entries { since: lastEntryId }` + - reconciles the documented entry graph against `leafId` + - performs exactly one explicit full rebuild for an unknown cursor, branch move, session replacement, unsupported entry, or invalid projection -2. **Session freshness monitoring** - - client polls `bridge_get_session_freshness` for active session - - bridge returns fingerprint (`mtime`, size, entryCount, lastEntryId, hash tail) - - on mismatch outside local mutation grace window: - - show coherency warning - - prompt user to **Sync now** +2. **Session invalidation plus safety monitoring** + - bridge pushes `bridge_session_invalidated` for mutations observed through Pi, imports, and navigation + - client immediately performs cursor resync + - a 60-second safety poll runs only while chat is foreground-active to detect terminal or external file edits + - on unsafe mismatch while the user is busy, show a coherency warning and **Sync now** action ## Consequences @@ -40,7 +40,7 @@ Use a two-part consistency strategy: ### Negative -- Additional control traffic for polling. +- A low-frequency safety request remains necessary for edits outside the active Pi process. - More client-side state/UX complexity (warning + sync paths). ## Alternatives considered @@ -49,5 +49,5 @@ Use a two-part consistency strategy: - Rejected: stale local state can persist unnoticed. 2. **Manual sync only (no polling)** - Rejected: poor UX; users often miss hidden divergence. -3. **Server push freshness invalidation only** - - Rejected for now: would require broader bridge/runtime event contract changes. +3. **Server push invalidation only** + - Rejected: the bridge cannot observe direct terminal writes to the session file. diff --git a/docs/adr/ADR-0004-retain-rpc-subprocess-boundary.md b/docs/adr/ADR-0004-retain-rpc-subprocess-boundary.md new file mode 100644 index 0000000..d911d15 --- /dev/null +++ b/docs/adr/ADR-0004-retain-rpc-subprocess-boundary.md @@ -0,0 +1,29 @@ +# ADR-0004: Retain the RPC subprocess boundary + +- **Status:** Accepted +- **Date:** 2026-07-13 + +## Context + +Pi Mobile needs authenticated remote access while preserving project isolation. Pi now offers both an embedding SDK and a supported RPC protocol. SDK embedding was reconsidered while reviewing the bridge architecture. + +## Decision + +Retain this architecture: + +**Android → authenticated WebSocket bridge → isolated `pi --mode rpc` subprocess per cwd.** + +The bridge remains responsible for authentication, network transport, bridge-owned session discovery, cwd and session locks, reconnect policy, process lifecycle, and only capabilities that Pi RPC does not expose. Current Pi RPC provides entry and tree reads; it does not provide cross-project session listing or direct navigation to an arbitrary tree entry. + +SDK embedding is rejected for this roadmap. RPC preserves process isolation and avoids implementing an RPC-compatibility dispatcher in the bridge. + +## Consequences + +- RPC compatibility is tested and documented as an external contract. +- Each cwd retains an isolated Pi process and control lock. +- Bridge-specific functionality must be kept to actual RPC gaps. +- The extra subprocess cost remains an operational tradeoff. + +## Revisit criteria + +Reconsider SDK embedding only when measured process-cost evidence shows a meaningful problem and a protocol-conformance suite can prove equivalent behavior, isolation, and compatibility. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2a7f893..5ef3266 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,6 +9,7 @@ This directory tracks durable architecture decisions for Pi Mobile. | [ADR-0001](ADR-0001-bridge-required.md) | Use a bridge between Android and pi RPC | Accepted | 2026-02-18 | | [ADR-0002](ADR-0002-cwd-process-and-locking.md) | Isolate runtime by cwd and enforce control locks | Accepted | 2026-02-18 | | [ADR-0003](ADR-0003-reconnect-resync-and-freshness.md) | Recover with resync and protect against cross-device drift | Accepted | 2026-02-18 | +| [ADR-0004](ADR-0004-retain-rpc-subprocess-boundary.md) | Retain the RPC subprocess boundary | Accepted | 2026-07-13 | ## Notes diff --git a/docs/architecture.md b/docs/architecture.md index e2b87af..bfcae18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ sequenceDiagram A->>B: bridge_acquire_control B-->>A: bridge_control_acquired - A->>B: rpc:get_state + rpc:get_messages + A->>B: rpc:get_state + rpc:get_entries B->>P: forward RPC P-->>B: response events B-->>A: rpc envelopes @@ -83,8 +83,8 @@ flowchart TD C -- Yes --> H[Wait for new bridge_hello] H --> S[Re-run bridge_set_cwd] S --> L[Re-acquire control lock] - L --> G[get_state + get_messages] - G --> U[Emit RpcResyncSnapshot] + L --> G[get_state + get_entries since lastEntryId] + G --> U[Reconcile entries against leafId] U --> V[ChatViewModel refreshes timeline/streaming state] ``` @@ -94,7 +94,7 @@ flowchart TD flowchart LR A[User selects tree entry] --> B[Android sends bridge_navigate_tree] B --> C[Bridge validates cwd + control lock] - C --> D[Bridge checks get_commands for pi-mobile-tree] + C --> D[Bridge invokes internal pi-mobile-tree command] D --> E[Bridge sends rpc prompt: pi-mobile-tree entryId statusKey] E --> F[Extension navigates tree + setEditorText + setStatus] F --> G[Bridge captures setStatus payload] @@ -119,6 +119,8 @@ stateDiagram-v2 - **Bridge is mandatory**: pi RPC is stdio-based; the bridge provides network transport + policy. - **Per-cwd subprocesses**: isolates project state and keeps tool cwd semantics correct. - **Control lock before RPC**: prevents concurrent writers to the same cwd/session. -- **Resync after reconnect**: avoids stale UI after transient network failures. -- **Freshness polling in chat**: detects cross-device/session-file drift and prompts user to sync. +- **Resync after reconnect**: uses durable entry IDs as cursors and performs one explicit full rebuild when the cursor or local projection is invalid. +- **Current tree paths**: active sessions use Pi `get_tree`; bridge-owned filesystem reads remain only for inactive-session browsing. The internal extension remains solely for navigation because Pi 0.80.6 has no navigation RPC command. +- **Freshness monitoring**: bridge-observed mutations push `bridge_session_invalidated` for immediate entry resync. A 60-second foreground-only safety poll covers terminal and other external file edits. +- **Retained boundary**: [ADR-0004](adr/ADR-0004-retain-rpc-subprocess-boundary.md) records Android → authenticated bridge → one `pi --mode rpc` process per cwd. - Decision rationale is captured in [ADRs](adr/README.md). diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index 766f487..f9c0bd3 100644 --- a/docs/bridge-protocol.md +++ b/docs/bridge-protocol.md @@ -103,6 +103,8 @@ If reconnecting with same `clientId`, `resumed` may be `true` and previous `cwd` | `bridge_acquire_control` | `bridge_control_acquired` | Acquires write lock for cwd/session | | `bridge_release_control` | `bridge_control_released` | Releases held lock | +The bridge also pushes `bridge_session_invalidated { reason }` to the controlling client after mutations observed through the active Pi process, session import/switch, or tree navigation. Clients should immediately run cursor synchronization. + ### `bridge_get_session_tree` filters Allowed values: @@ -115,6 +117,8 @@ Allowed values: Unknown filter -> `bridge_error` (`invalid_tree_filter`). +The bridge rejects WebSocket messages larger than `BRIDGE_WEBSOCKET_MAX_PAYLOAD_BYTES` (16 MiB by default). + ### `bridge_get_session_freshness` Request payload: @@ -176,6 +180,7 @@ Notes: - writes the uploaded JSONL into the bridge session directory - switches the active pi runtime to the imported session - filename is sanitized and uniqued server-side to avoid path traversal and overwrites +- UTF-8 content is limited by `BRIDGE_IMPORT_MAX_BYTES` (10 MiB by default); oversized content returns `import_payload_too_large` without closing the connection ### `bridge_navigate_tree` @@ -202,7 +207,7 @@ Response payload: ## RPC Channel Messages -`rpc` channel forwards pi RPC commands/events. +`rpc` channel forwards pi RPC commands/events unchanged. Android uses `get_entries` for active-session synchronization and `get_tree` for active topology. Cross-project session listing and inactive-session tree browsing remain bridge-owned. Direct tree navigation remains bridge-owned because Pi 0.80.6 does not expose a navigation RPC command. ### Preconditions for sending RPC payloads @@ -253,6 +258,7 @@ Common codes: - `session_tree_failed` - `session_freshness_failed` - `session_import_failed` +- `import_payload_too_large` ## Health Endpoint diff --git a/docs/codebase.md b/docs/codebase.md index ce8283e..a83d203 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -42,6 +42,8 @@ The app never talks directly to a pi process. It talks to the bridge, which: - enforces single-client control lock per cwd/session - forwards RPC events and bridge control messages +This retained boundary is documented in [ADR-0004](adr/ADR-0004-retain-rpc-subprocess-boundary.md): one isolated `pi --mode rpc` process per cwd. + ## Repository Layout | Path | Purpose | @@ -123,7 +125,7 @@ On reconnect, `PiRpcConnection`: - waits for new `bridge_hello` - re-acquires cwd/control if needed -- emits `RpcResyncSnapshot` after fresh `get_state + get_messages` +- emits `RpcResyncSnapshot` after `get_state + get_entries`, using the last entry ID as a reconnect cursor This keeps timeline and streaming flags consistent after network interruptions. @@ -133,7 +135,7 @@ Tree flow uses both bridge control and internal extension command: 1. App sends `bridge_navigate_tree { entryId }` 2. Bridge checks internal command availability (`get_commands`) -3. Bridge sends RPC `prompt` with internal command: +3. Current source checks command availability, then sends RPC `prompt` with internal command: - `/pi-mobile-tree ` 4. Extension emits `setStatus(statusKey, JSON payload)` 5. Bridge parses payload and replies with `bridge_tree_navigation_result` @@ -151,7 +153,7 @@ To protect against cross-device edits on the same session file: - emit warning notification with lock owner hints 5. User can trigger **Sync now** to force timeline reload and clear warning -This helps avoid writing on stale in-memory state after another client changed the session. +This helps avoid writing on stale in-memory state after another client changed the session. Current Pi RPC provides `get_tree` and cursor-based `get_entries`; plans 003–004 may simplify the bridge tree read and polling paths, but that future behavior is not implemented here. ## Bridge Control Model diff --git a/docs/dependency-matrix.md b/docs/dependency-matrix.md new file mode 100644 index 0000000..3dc8985 --- /dev/null +++ b/docs/dependency-matrix.md @@ -0,0 +1,44 @@ +# Dependency and toolchain matrix + +Validated on 2026-07-13. Targets are stable releases; previews were excluded. + +| Component | Previous | Target | Rationale | +|---|---:|---:|---| +| Android Gradle Plugin | 8.5.2 | 8.13.2 | Latest AGP 8 stable line with Android API 36 support. AGP 9 changes the Kotlin integration model and is deferred rather than combining that migration with the revival feature set. | +| Gradle | 8.7 | 8.14.5 | Latest Gradle 8 stable release; retains the existing Gradle 8 build model and passes AGP 8.13 checks. | +| JDK/JVM target | 21 | 21 | Existing project baseline; supported by Gradle 8.14.5 and AGP 8.13. | +| Kotlin | 1.9.24 | 2.2.21 | Stable Kotlin 2 release compatible with AGP 8.13 and the Compose compiler Gradle plugin. Kotlin 2.4 is not selected because the current Android plugin compatibility path is AGP 9. | +| Compose compiler | 1.5.14 | Kotlin plugin 2.2.21 | Kotlin 2 uses `org.jetbrains.kotlin.plugin.compose` at the Kotlin version. | +| Compose BOM | 2024.06.00 | 2026.06.00 | Current stable Compose BOM at execution time. | +| Kotlinx Kover | 0.8.3 | 0.9.8 | Current stable plugin; fixes clean-test instrumentation compatibility with Kotlin 2.2/Gradle 8.14.5. | +| AndroidX Navigation Compose | 2.7.7 | 2.9.8 | Current stable Navigation release. | +| compileSdk / targetSdk | 34 / 34 | 36 / 36 | Android 16 stable SDK and current target baseline. | +| AndroidX security-crypto | 1.1.0-alpha06 | removed | The package is deprecated. Tokens now use platform Android Keystore AES-256-GCM. | +| Google Code Scanner | — | 16.1.0 | Official permissionless Google Play services scanner used only for host pairing QR codes. | +| Node QRCode | — | 1.5.4 | Terminal QR rendering for `pnpm pair`; no bridge runtime endpoint or pairing service is added. | +| Node.js | 22 in CI | 22 LTS in CI | Bridge CI remains on a supported LTS runtime; local Node 24 also passes bridge checks. | +| pnpm | 9 in CI | 10.33.0 | Matches the lockfile/tool used for final frozen installs. | +| Pi | implicit | 0.80.6 minimum | Required compatibility baseline for lifecycle, entries, tree, and session-format behavior. | + +## Compatibility and migration notes + +- Existing `EncryptedSharedPreferences` tokens cannot be decrypted after removing the deprecated library. The approved migration clears only the old encrypted token preference file, preserves host profiles, and shows a restrained re-entry notice. No plaintext conversion is attempted. +- New token values are encrypted with a non-exportable Android Keystore AES key and AES-GCM. Corrupted ciphertext is removed and treated as missing. +- App backup is disabled so encrypted token payloads are not restored without their device-bound Keystore key. +- Release minification remains disabled because enabling it without device release smoke coverage would broaden this migration. + +## Authoritative sources + +- AGP releases and compatibility: https://developer.android.com/build/releases/gradle-plugin +- AGP 8.13 notes: https://developer.android.com/build/releases/past-releases/agp-8-13-0-release-notes +- Gradle 8.14.5 release: https://docs.gradle.org/8.14.5/release-notes.html +- Kotlin releases: https://kotlinlang.org/docs/releases.html +- Compose compiler migration: https://kotlinlang.org/docs/compose-compiler-migration-guide.html +- Compose BOM: https://developer.android.com/develop/ui/compose/bom +- Kover plugin: https://plugins.gradle.org/plugin/org.jetbrains.kotlinx.kover +- Navigation releases: https://developer.android.com/jetpack/androidx/releases/navigation +- Android 16 SDK setup: https://developer.android.com/about/versions/16/setup-sdk +- Android Keystore: https://developer.android.com/privacy-and-security/keystore +- Deprecated EncryptedSharedPreferences: https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences +- Google Code Scanner: https://developers.google.com/ml-kit/vision/barcode-scanning/code-scanner +- Node QRCode package: https://www.npmjs.com/package/qrcode diff --git a/docs/final-acceptance.md b/docs/final-acceptance.md index bc60de7..d798c86 100644 --- a/docs/final-acceptance.md +++ b/docs/final-acceptance.md @@ -1,5 +1,7 @@ # Final Acceptance Report +> **Historical report (2025-02-14).** This records the implementation state at the commit range below. It is superseded as current acceptance evidence and must not be used as a description of current Pi capabilities. + Pi Mobile Android Client - Phase 1-8 Completion ## Summary diff --git a/docs/onboarding.md b/docs/onboarding.md new file mode 100644 index 0000000..924a692 --- /dev/null +++ b/docs/onboarding.md @@ -0,0 +1,28 @@ +# Onboarding and recovery + +## First successful connection + +1. **Prepare the computer** — install Pi 0.80.6 or newer, start the authenticated bridge, and connect the phone and computer to the same Tailnet. +2. **Print pairing QR** — from `bridge/`, run `pnpm pair`. The command reads the existing `.env`, discovers the computer's Tailscale MagicDNS name, and prints a QR containing the host, port, TLS setting, and existing bridge token. If Tailscale discovery is unavailable, run `pnpm pair -- --host `. +3. **Scan and save** — in Pi Mobile, open **Hosts**, tap **Scan QR**, scan the terminal code, review the populated connection, and save it. Manual entry remains available as a fallback. Keep the terminal QR private because it contains the bridge token. +4. **Validate readiness** — connection diagnostics distinguish reachability, authentication, and Pi RPC readiness. Use **Test** to verify all stages. +5. **Choose work** — the app opens Sessions after the first connection is saved. Choose a recent project/session or create a session. +6. **Chat** — an active session makes Chat primary. Back returns to Sessions. + +**Hosts** remains available in the navigation drawer after setup so a broken connection can always be edited, tested, deleted, or replaced. + +Non-secret connection fields may be re-entered when editing. Existing tokens remain encrypted unless the user explicitly types a replacement. + +## Recovery map + +| State | Explanation | Primary action | +|---|---|---| +| No network / bridge unreachable | Tailscale, address, port, or bridge process is unavailable | Check connectivity, then **Try again** | +| Authentication rejected | The supplied bridge token does not match | **Update token** | +| Bridge incompatible | Required authenticated RPC behavior is unavailable | Upgrade/restart the bridge | +| Pi missing or RPC unavailable | The bridge cannot start or query Pi | Install Pi 0.80.6+, then **Test Pi again** | +| No model credentials | Pi starts but cannot use a configured model | Configure provider credentials on the computer | +| Control lock held | Another client controls the cwd/session | Return to Sessions or release the other client | +| Session unavailable | Session was moved, deleted, or cannot be read | Refresh Sessions and choose another session | + +Technical diagnostics may be copied for troubleshooting, but primary recovery text is typed and sanitized. Tokens, authorization headers, private prompts, and raw session contents must never be rendered or logged. diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index 571fc15..f7a9c11 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -115,6 +115,30 @@ if (buffer.isBackpressuring()) { } ``` +## Synchronization Payload Baseline + +The synchronization counters are exposed as `SessionSyncMetrics`: `fullRebuilds`, `incrementalEntries`, and `safetyPolls`. + +Reproduce payload counts without a device: + +```bash +./gradlew :core-net:test --tests com.ayagmar.pimobile.corenet.PiRpcConnectionTest +./gradlew :app:testDebugUnitTest --tests com.ayagmar.pimobile.sessions.SessionEntryProjectionTest +``` + +For a session with 10,000 existing entries followed by 10 new entries: + +| Scenario | Before | After | +|---|---:|---:| +| Initial open | 1 `get_messages` full-history response | 1 full `get_entries` response | +| Ordinary reconnect with no changes | 1 `get_messages` full-history response | 1 `get_entries` response containing 0 entries | +| Reconnect after 10 appended entries | 1 `get_messages` full-history response | 1 `get_entries` response containing 10 entries | +| Unknown cursor or branch movement | 1 full-history response | 1 failed/incompatible incremental response plus exactly 1 full rebuild | +| Foreground safety checks per minute | 15 at the former 4-second interval | 1 at the 60-second interval | +| Background/inactive safety checks | 15 per minute at the former interval | 0 | + +These are protocol payload counts derived from deterministic fixtures, not device timing claims. + ## Current Baseline (v1.0) *To be populated with actual measurements* diff --git a/docs/pi-rpc-compatibility.md b/docs/pi-rpc-compatibility.md new file mode 100644 index 0000000..11ded0a --- /dev/null +++ b/docs/pi-rpc-compatibility.md @@ -0,0 +1,21 @@ +# Pi RPC compatibility + +Pi Mobile requires and is tested with `@earendil-works/pi-coding-agent` **0.80.6 or newer within the 0.80 release line**. Version 0.80.6 is the minimum because the client relies on the `agent_settled` lifecycle boundary and the `max` thinking level. + +The bridge remains a transparent JSON pass-through. It must not normalize Pi command IDs, fields, or asynchronous events. + +## Upgrade procedure + +1. Read the installed Pi `CHANGELOG.md` and `docs/rpc.md` completely. +2. Update the documented minimum only after compatibility tests pass. +3. Refresh sanitized files under `core-rpc/src/test/resources/rpc/`; never copy credentials, paths, prompts, or private session content. +4. Run `./gradlew :core-rpc:test :core-net:test :app:testDebugUnitTest` and `(cd bridge && pnpm run check)`. +5. Exercise retry, compaction, queued continuation, abort, tree, and reconnect behavior against the installed Pi. + +## Consumed current capabilities + +The typed contract includes `agent_settled`, `get_entries`, `get_tree`, `clone`, canonical `contextUsage`, and thinking level `max`. Tree and incremental synchronization adoption is tracked separately from protocol typing. + +## Session entry interpretation policy + +For Pi 0.80.6, Pi Mobile treats the package's published `docs/session-format.md` and public `SessionEntry` definitions as part of the compatibility contract for interpreting `get_entries` and `get_tree`. Active branches are resolved through `leafId` and `parentId`; compaction follows the documented `firstKeptEntryId` behavior. Unknown entry variants must cause one explicit full rebuild rather than being guessed. Sanitized fixtures protect this boundary during Pi upgrades. diff --git a/docs/pi-upstream-opportunities.md b/docs/pi-upstream-opportunities.md new file mode 100644 index 0000000..15ab54e --- /dev/null +++ b/docs/pi-upstream-opportunities.md @@ -0,0 +1,31 @@ +# Current Pi RPC gap assessment + +**Assessed:** 2026-07-13 +**Pi version:** 0.80.6 +**Authority:** installed `@earendil-works/pi-coding-agent` `docs/rpc.md` + +## Delivered by current Pi + +- `get_entries` returns append-order entries, stable cursor IDs, and the current `leafId`. +- `get_tree` returns authoritative session topology and the current `leafId`. +- `clone` duplicates the active branch into a new session. + +These capabilities are no longer Pi Mobile bridge feature gaps. Adoption in Pi Mobile is tracked separately; support in Pi does not imply that every current client path consumes it yet. + +## Remaining gaps relevant to Pi Mobile + +### Cross-project session discovery + +Current Pi RPC has no `list_sessions` command. The bridge must continue indexing configured session directories so Android can browse inactive projects and sessions before a Pi process exists. + +### Direct tree navigation + +Current Pi RPC can read entries and topology but has no command that moves the active leaf to an arbitrary entry. Pi Mobile therefore retains its internal navigation extension and bridge control message. + +### External-file freshness notification + +RPC reports changes made by its own process but does not notify a running process when another process edits the session file. The bridge/client still need a bounded freshness fallback for external edits. + +## Review policy + +Re-check this assessment against the installed Pi RPC documentation during compatibility upgrades. Prefer standard RPC commands whenever they replace a bridge-specific capability, without moving session discovery or navigation until equivalent upstream contracts exist. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..fbca910 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,21 @@ +# Release verification + +Pi Mobile does not store signing credentials in the repository. The checked-in release task produces an unsigned/default release artifact for static verification. + +```bash +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) +``` + +Expected artifacts: + +- `app/build/outputs/apk/debug/app-debug.apk` +- `app/build/outputs/apk/release/app-release-unsigned.apk` + +Before distribution: + +1. Review `docs/dependency-matrix.md` and Pi compatibility policy. +2. Complete `docs/revival-acceptance.md` on an operator-owned device. +3. Sign outside the repository using protected operator/CI key material. +4. Verify the signed APK and preserve checksums, version, commit, and acceptance evidence. +5. Never commit keystores, passwords, service credentials, or generated signing configuration. diff --git a/docs/revival-acceptance.md b/docs/revival-acceptance.md new file mode 100644 index 0000000..9f93fc1 --- /dev/null +++ b/docs/revival-acceptance.md @@ -0,0 +1,107 @@ +# Revival acceptance checklist + +All device-only results are **PENDING — operator-owned**. Do not replace this status without recording real evidence. + +## Prerequisites + +- JDK 21, Android SDK/platform 36, adb, Node 22+, pnpm 10, and Pi 0.80.6+ +- A bridge configured with a fresh `BRIDGE_AUTH_TOKEN` and reachable Tailnet address +- An emulator/device with no private data in screenshots or logs + +Build artifacts: + +```bash +./gradlew :app:assembleDebug :app:assembleRelease +ls -l app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk +``` + +Evidence header: + +```text +Device model: +Android/API: +App commit: +Pi version: +Node/pnpm version: +Bridge commit/config (no secrets): +Date/operator: +``` + +## Installation and onboarding + +1. `adb uninstall com.ayagmar.pimobile || true` +2. `adb install app/build/outputs/apk/debug/app-debug.apk` +3. Launch: `adb shell am start -n com.ayagmar.pimobile/.MainActivity` +4. Confirm first launch shows **Connect your computer**, not an empty CRUD list. +5. Try an unreachable host; expect actionable network recovery. +6. Try a bad token; expect authentication recovery and no token text on screen/logcat. +7. Save and test a valid bridge; expect Sessions to open. +8. Restart the app; expect host metadata retained and stored token never rendered. + +Result: **PENDING — operator-owned** +Evidence/notes: ______________________________ + +## Sessions and chat + +For each action, verify one resulting item/state and no duplicate timeline message: + +- list and refresh sessions; empty state offers a primary action +- create and resume a session +- rename, fork, tree jump, import JSONL, export HTML, and compact +- send text and image prompts +- observe text, thinking, tool execution, retry/compaction, and settled lifecycle +- abort, steer, and follow-up +- respond to extension select/confirm/input/editor; inspect widget/status updates +- disconnect/reconnect; ordinary reconnect must preserve timeline without full-history duplication +- edit the active session externally; foreground safety sync must detect it within 60 seconds +- navigate a branch without appending; expect exactly one safe rebuild + +Result: **PENDING — operator-owned** +Evidence/notes: ______________________________ + +## Multiple hosts and recovery + +1. Add a second computer and switch hosts. +2. Hold the same cwd lock from another client; expect lock guidance, not a raw exception. +3. Stop the bridge and restart it during chat; expect reconnect/resync. +4. Remove model credentials; expect Pi readiness guidance. +5. Corrupt only a test token ciphertext, relaunch, and verify the token is treated as missing without a crash. + +Result: **PENDING — operator-owned** +Evidence/notes: ______________________________ + +## Accessibility and lifecycle + +1. Verify all primary controls have at least 48dp targets and meaningful TalkBack labels. +2. Test TalkBack order through onboarding, Sessions, and composer. +3. Set font scale to 1.3–1.5x; verify portrait and landscape without clipped primary actions. +4. Test keyboard Next/Done behavior and token masking. +5. Background chat for over 60 seconds; confirm safety polling pauses, then resumes on foreground. +6. Rotate and background/restore during idle and streaming; verify current session restoration. + +Result: **PENDING — operator-owned** +Evidence/notes: ______________________________ + +## Release APK + +The repository does not contain signing credentials. Install a safely signed operator copy of `app/build/outputs/apk/release/app-release-unsigned.apk`, launch it, connect, resume, prompt, and reconnect. Record signing method without recording key material. + +Result: **PENDING — operator-owned** +Evidence/notes: ______________________________ + +## Non-device gate + +```bash +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) +``` + +Expected: every command exits 0; APK paths above exist; production audit has no high vulnerabilities. + +Recorded non-device result (2026-07-13): **PASS** + +- Android clean/static/unit/lint/debug/release gate: exit 0 +- Bridge frozen install/check/production audit: exit 0; no known vulnerabilities +- Debug APK: `app/build/outputs/apk/debug/app-debug.apk` +- Release APK: `app/build/outputs/apk/release/app-release-unsigned.apk` +- Connected/manual sections above: **PENDING — operator-owned** diff --git a/docs/spikes/tree-navigation-rpc-vs-bridge.md b/docs/spikes/tree-navigation-rpc-vs-bridge.md index a8fddde..ac767dd 100644 --- a/docs/spikes/tree-navigation-rpc-vs-bridge.md +++ b/docs/spikes/tree-navigation-rpc-vs-bridge.md @@ -1,5 +1,7 @@ # Tree navigation spike: RPC-only vs bridge endpoint +> **Historical spike.** Its RPC capability conclusion is superseded: current Pi provides `get_entries` and `get_tree`. Direct tree navigation remains absent, so the internal navigation mechanism is still required. + Date: 2026-02-15 ## Goal diff --git a/docs/testing.md b/docs/testing.md index 71a88cb..02a5951 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -137,17 +137,19 @@ Or use Android Studio's "Apply Changes" for hot reload of Compose previews. ## Running Tests -Unit tests (on JVM): -```bash -./gradlew test -``` +Use JDK 21, Android SDK 36, Node 22+, and pnpm 10. + +Complete non-device gate: -All quality checks: ```bash -./gradlew ktlintCheck detekt test +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) ``` -Bridge tests: +Compile connected tests without launching an emulator/device: + ```bash -cd bridge && pnpm test +./gradlew :app:compileDebugAndroidTestKotlin ``` + +Device acceptance is operator-owned. Follow [`revival-acceptance.md`](revival-acceptance.md) rather than inventing results. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23..4f5eb9d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/plans/001-clean-stale-documentation.md b/plans/001-clean-stale-documentation.md new file mode 100644 index 0000000..42b01fb --- /dev/null +++ b/plans/001-clean-stale-documentation.md @@ -0,0 +1,137 @@ +# Plan 001: Clean stale documentation and record the RPC architecture decision + +> **Executor instructions**: Follow this plan step by step. Run every verification command before continuing. Do not implement runtime changes. Update this plan's row in `plans/README.md` when done. +> +> **Drift check (run first)**: `git diff --stat ca7eaa2..HEAD -- README.md docs plans` +> If the cited stale statements no longer exist, stop and report which parts were already resolved. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: docs +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +The repository simultaneously describes the current RPC subprocess architecture and an untracked plan to replace it with the SDK. Several documents also describe RPC gaps that current Pi has closed. A fresh executor could follow the wrong architecture. This plan makes RPC the explicit maintained decision and separates historical records from current documentation. + +## Current state + +- `README.md:70` installs the old `@mariozechner/pi-coding-agent` package. +- `README.md:75` uses `github.com/yourusername/pi-mobile.git`. +- `docs/ai/pi-bridge-sdk-migration-spec-plan.md` is an untracked, stale SDK migration proposal. Do not preserve it as an active plan. +- `docs/pi-upstream-opportunities.md` is untracked and claims RPC lacks `get_tree`; current Pi RPC provides `get_entries`, `get_tree`, and `clone`. +- `docs/spikes/tree-navigation-rpc-vs-bridge.md`, `docs/final-acceptance.md`, and ADR-0001 contain historically valid but now stale statements about tree/session capabilities. +- ADR-0002 records the still-valid one-process-per-cwd and locking decision. + +Authoritative local Pi docs: + +- `/home/ayagmar/.fnm/node-versions/v24.12.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/README.md` +- corresponding `docs/rpc.md` + +If these paths do not exist in the executor environment, use the docs bundled with the installed `@earendil-works/pi-coding-agent`; do not rely on memory. + +## Commands you will need + +| Purpose | Command | Expected | +|---|---|---| +| Find stale references | `rg -n '@mariozechner|yourusername|SDK migration|get_tree|get_entries|list-sessions' README.md docs --glob '*.md'` | only intentional historical references remain | +| Markdown links | `find README.md docs -name '*.md' -print0 \| xargs -0 grep -nE '\]\([^)]*\.md[^)]*\)'` | reviewable link list | +| Working tree | `git status --short` | only scoped docs and plan status changed | + +## Scope + +**In scope**: +- `README.md` +- `docs/README.md` +- `docs/architecture.md` +- `docs/codebase.md` +- `docs/extensions.md` +- `docs/bridge-protocol.md` +- `docs/final-acceptance.md` +- `docs/pi-upstream-opportunities.md` +- `docs/spikes/tree-navigation-rpc-vs-bridge.md` +- `docs/adr/ADR-0001-bridge-required.md` +- `docs/adr/ADR-0002-cwd-process-and-locking.md` +- `docs/adr/README.md` +- `docs/adr/ADR-0004-retain-rpc-subprocess-boundary.md` (create) +- `docs/ai/pi-bridge-sdk-migration-spec-plan.md` +- `plans/README.md` + +**Out of scope**: +- Runtime source or tests +- Rewriting historical progress files under `docs/ai/` +- Claiming `list_sessions` exists in Pi RPC; current RPC has tree/entry commands but session discovery remains bridge-owned + +## Git workflow + +Use branch `advisor/001-clean-stale-docs`. Follow conventional commits; example: `docs: align architecture with current pi rpc`. Do not push unless asked. + +## Steps + +### Step 1: Correct user-facing setup + +Update package name to `@earendil-works/pi-coding-agent`, replace the placeholder clone URL with the repository's actual origin (`git remote get-url origin`), and state a supported Pi version/range based on the installed package version. Add commands to verify `pi --version`, bridge startup, and `/health` when enabled. Do not expose values from `bridge/.env`. + +**Verify**: `rg -n '@mariozechner|yourusername' README.md` returns no matches. + +### Step 2: Record the retained architecture + +Create ADR-0004. It must say: + +- Android → authenticated WebSocket bridge → isolated `pi --mode rpc` subprocess per cwd. +- SDK embedding was reconsidered and rejected for this roadmap because RPC gives process isolation and avoids implementing an RPC-compatibility dispatcher. +- The bridge remains responsible for auth, transport, discovery, locks, reconnect policy, and only those Pi gaps not available through RPC. +- Revisit SDK embedding only with measured process-cost evidence and a protocol-conformance suite. + +Update ADR index and architecture/codebase docs accordingly. + +**Verify**: `rg -n 'ADR-0004|one.*process.*cwd|pi --mode rpc' docs/adr docs/architecture.md docs/codebase.md` finds the new decision and current architecture. + +### Step 3: Retire stale active plans and qualify historical documents + +Delete the untracked SDK migration proposal rather than rewriting it. Rewrite `docs/pi-upstream-opportunities.md` as a dated current-gap assessment: mark `get_tree` and `get_entries` as delivered upstream, identify session listing and direct navigation/freshness only if still absent in the current installed RPC docs. Add a prominent “historical spike” notice to the old tree spike and final acceptance report; do not falsify their original results. + +Update `docs/README.md` to distinguish maintained docs from historical `docs/ai` artifacts. + +**Verify**: + +```bash +test ! -e docs/ai/pi-bridge-sdk-migration-spec-plan.md +rg -n 'historical|superseded|current Pi' docs/README.md docs/final-acceptance.md docs/spikes/tree-navigation-rpc-vs-bridge.md docs/pi-upstream-opportunities.md +``` + +Expected: deleted stale migration file and explicit historical/current labels. + +### Step 4: Synchronize bridge and extension docs + +Document that current source still uses bridge-owned session listing and internal navigation/workflow extensions, while plans 003–004 may simplify this later. Do not describe future work as already implemented. + +**Verify**: manually compare documented extension filenames and bridge message names with `bridge/src/extensions/` and `bridge/src/server.ts`; all referenced names exist. + +## Test plan + +Documentation-only. Run stale-reference searches and check all relative Markdown links resolve with a small script or available link checker. At minimum, extract local `.md` links and test each target exists. + +## Done criteria + +- [ ] Old package and placeholder repository references are gone. +- [ ] ADR-0004 records the RPC decision and is indexed. +- [ ] SDK migration proposal is removed. +- [ ] Historical reports are clearly labeled. +- [ ] Current Pi-delivered RPC features are no longer listed as missing. +- [ ] No runtime files changed. +- [ ] Plan row is DONE. + +## STOP conditions + +- The installed Pi RPC docs no longer expose `get_entries` or `get_tree`. +- `git remote get-url origin` does not identify a canonical repository URL. +- Any requested documentation correction requires claiming unimplemented runtime behavior. + +## Maintenance notes + +Reviewers should distinguish “supported by Pi” from “already consumed by Pi Mobile.” Update the compatibility statement whenever the pinned/tested Pi version changes. diff --git a/plans/002-secure-and-pin-the-bridge.md b/plans/002-secure-and-pin-the-bridge.md new file mode 100644 index 0000000..e748f77 --- /dev/null +++ b/plans/002-secure-and-pin-the-bridge.md @@ -0,0 +1,98 @@ +# Plan 002: Secure, pin, and bound the bridge runtime + +> **Executor instructions**: Execute every step and verification gate. Preserve the external WebSocket envelope and error shapes. Update `plans/README.md` when done. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- bridge README.md docs/bridge-protocol.md plans` + +## Status + +- **Priority**: P1 +- **Effort**: M +- **Risk**: MED +- **Depends on**: `plans/001-clean-stale-documentation.md` +- **Category**: security +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +The bridge is a network-facing remote-code-control service. Its current `ws` version has a high-severity denial-of-service advisory, the WebSocket server has no project-specific payload bound, and the repository does not pin or verify the Pi executable it forwards to. + +## Current state + +- `bridge/package.json` declares `ws: ^8.18.3`; `pnpm audit --prod` reports GHSA-96hv-2xvq-fx4p, patched in 8.21.0. +- `bridge/src/server.ts:107`: `new WebSocketServer({ noServer: true })`. +- Session import accepts a JSONL string and writes it after validation; no documented upload-size setting exists. +- `bridge/src/server.ts` creates forwarders using the `pi` command from PATH. +- Configuration parsing conventions live in `bridge/src/config.ts` and tests in `bridge/test/config.test.ts`. +- Server validation tests use `bridge/test/server.test.ts`; match these patterns. + +## Commands + +| Purpose | Command | Expected | +|---|---|---| +| Install | `cd bridge && pnpm install --frozen-lockfile` | exit 0 | +| Check | `cd bridge && pnpm run check` | lint/typecheck/56+ tests pass | +| Audit | `cd bridge && pnpm audit --prod` | no high vulnerabilities | + +## Scope + +**In scope**: `bridge/package.json`, `bridge/pnpm-lock.yaml`, `bridge/src/config.ts`, `bridge/src/server.ts`, relevant bridge tests, `README.md`, `docs/bridge-protocol.md`, `plans/README.md`. + +**Out of scope**: SDK migration, Android UI, auth redesign, TLS termination, changing existing envelope/error shapes. + +## Git workflow + +Branch `advisor/002-secure-bridge`; conventional commit example `fix(bridge): bound websocket payloads`. + +## Steps + +### Step 1: Upgrade vulnerable runtime dependencies + +Verify the latest stable compatible `ws` 8.x version from npm/official release information, choose at least 8.21.0, update lockfile, and record the version/rationale in the commit body or plan status note. Do not upgrade unrelated major versions. + +**Verify**: `cd bridge && pnpm audit --prod` reports no high advisory for `ws`; `pnpm run check` passes. + +### Step 2: Add explicit limits + +Add parsed positive integer configuration for WebSocket maximum payload and imported session maximum bytes, with conservative documented defaults. Pass `maxPayload` to `WebSocketServer`. Reject oversized imports before parsing/writing using the existing `bridge_error` shape and a stable new error code. Ensure UTF-8 byte length, not JavaScript character count, is checked. + +Add tests for defaults, overrides, invalid settings, exact-boundary acceptance, over-bound rejection, and connection survival after a rejected import. + +**Verify**: `cd bridge && pnpm test -- config.test.ts server.test.ts` passes with new tests. + +### Step 3: Make the Pi executable and compatibility visible + +Add `BRIDGE_PI_COMMAND` with default `pi`; use it when creating forwarders. At startup, run a bounded `pi --version` probe before accepting traffic, log the detected version without credentials, and fail with an actionable error if Pi is absent. If exact supported-version enforcement cannot be established from repository policy, warn on unknown versions rather than inventing a range. + +Tests must inject a fake probe—never require global Pi during unit tests. + +**Verify**: bridge tests cover success, missing executable, timeout, and configured command. + +### Step 4: Document operations + +Add all new variables to configuration docs and create `bridge/.env.example` containing placeholders only. Ensure `.env` remains ignored and no secret value is copied. + +**Verify**: `git check-ignore bridge/.env` succeeds; `git diff --check` succeeds. + +## Test plan + +Use existing config/server test structure. Add boundary-focused tests and a startup-probe unit test. Run full bridge check and production audit. + +## Done criteria + +- [ ] Patched `ws` is locked. +- [ ] WebSocket and import payloads are bounded and tested. +- [ ] Pi command is configurable and startup failure is actionable. +- [ ] `.env.example` has placeholders only. +- [ ] Existing wire shapes remain compatible. +- [ ] Plan row is DONE. + +## STOP conditions + +- Fixing the advisory requires a `ws` major upgrade with API changes. +- Current server tests cannot inject startup/process dependencies without an architectural refactor larger than this plan. +- Any test would need a real credential or globally installed Pi. + +## Maintenance notes + +Review payload defaults against real large-session imports. Limits protect memory but should produce a clear user-facing recovery path, not a generic disconnect. diff --git a/plans/003-current-pi-rpc-conformance.md b/plans/003-current-pi-rpc-conformance.md new file mode 100644 index 0000000..33f1656 --- /dev/null +++ b/plans/003-current-pi-rpc-conformance.md @@ -0,0 +1,107 @@ +# Plan 003: Bring Pi Mobile up to the current Pi RPC contract + +> **Executor instructions**: Treat the installed Pi `docs/rpc.md` as authoritative. Add characterization tests before changing behavior. Update `plans/README.md` when complete. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- core-rpc core-net app/src bridge/test docs plans` + +## Status + +- **Priority**: P1 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/002-secure-and-pin-the-bridge.md` +- **Category**: migration +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +Pi added lifecycle and session commands after this client was written. The app currently treats `agent_end` as final even when retry, compaction, or queued continuation may follow; it also omits current command/field variants. A conformance fixture is needed so future Pi upgrades fail in tests rather than at runtime. + +## Current state + +- Current local Pi docs: `.../@earendil-works/pi-coding-agent/docs/rpc.md`. +- Current documented additions include `agent_settled`, `get_entries`, `get_tree`, `clone`, `contextUsage`, and model-dependent `max` thinking. +- `core-rpc/.../RpcIncomingMessage.kt:122` models `AgentEndEvent` but no settled event. +- `ChatViewModel.kt:975` clears running state on `AgentEndEvent`. +- `RpcSessionController.kt:980` reacts to agent end/error for controller state. +- `ChatScreen.kt:2811` hardcodes thinking through `xhigh`. +- `RpcSessionController.kt:1354` reads legacy `context`; current stats use `contextUsage`. +- Parser patterns: `RpcMessageParserTest.kt`; controller mapping patterns: `RpcSessionControllerTest.kt`. + +## Commands + +```bash +./gradlew :core-rpc:test :core-net:test :app:testDebugUnitTest +(cd bridge && pnpm run check) +./gradlew ktlintCheck detekt test +``` + +All must exit 0. If Gradle cannot download dependencies, retry only after network is restored; do not mark done with Android checks skipped. + +## Scope + +**In scope**: RPC models/parser/tests in `core-rpc`; connection/controller/ViewModel/UI mappings and tests in `core-net` and `app`; bridge protocol conformance tests; compatibility docs; plan status. + +**Out of scope**: consuming `get_tree/get_entries` beyond typed support (plan 004), visual redesign, SDK embedding, removing legacy field fallbacks. + +## Steps + +### Step 1: Capture the authoritative protocol surface + +Read current `rpc.md` completely. Create a checked-in, sanitized conformance fixture set under `core-rpc/src/test/resources/rpc/` covering every command/event the app consumes, including unknown fields. Add a short compatibility document naming the Pi version used to generate/verify fixtures. No session contents or credentials. + +**Verify**: fixture parser test enumerates all fixture files and parses each successfully. + +### Step 2: Model new lifecycle and command types + +Add typed support for `agent_settled`, `get_entries`, `get_tree`, and `clone`, preserving `GenericRpcEvent` handling for future unknown events. Model only fields documented by Pi and needed by the client. Add command encoding and parser tests. + +**Verify**: focused core tests pass and `rg 'agent_settled|get_entries|get_tree|clone' core-rpc core-net` finds models plus tests. + +### Step 3: Correct lifecycle semantics + +Keep `agent_end` useful for per-run diagnostics but do not mark the overall session idle solely from it when Pi may continue automatically. Use `agent_settled` as the definitive settled boundary. Preserve compatibility with older supported Pi only if the compatibility policy explicitly includes versions without `agent_settled`; implement that fallback at one documented boundary, not in multiple ViewModels. + +Test: plain completion, retry after `agent_end`, overflow compaction retry, queued follow-up, abort, reconnect/resync during activity. + +**Verify**: ViewModel/controller tests prove controls remain “running” between `agent_end` and `agent_settled`. + +### Step 4: Align stats, thinking, and state fields + +Parse canonical `contextUsage.tokens/contextWindow/percent`, including documented nulls immediately after compaction, while retaining tested legacy fallbacks. Derive available thinking levels from model/RPC capability where possible; if RPC does not expose an explicit list, include `max` and handle a rejected set command visibly without corrupting local state. + +**Verify**: tests cover canonical, null-after-compaction, and legacy stats; thinking tests cover rejection rollback. + +### Step 5: Add bridge pass-through conformance + +Add bridge tests proving command IDs, unknown fields, events without IDs, asynchronous interleaving, `agent_settled`, and new commands pass through unchanged. The bridge must not reimplement or normalize Pi RPC payloads. + +**Verify**: `cd bridge && pnpm run check` passes. + +### Step 6: Update compatibility documentation + +Document supported/tested Pi version, upgrade procedure, fixture refresh procedure, and current RPC capabilities. Do not claim plan 004 behavior yet. + +## Test plan + +Tests are required before implementation for each corrected behavior. Follow existing parser/controller tests. Add at least one cross-layer test that sends fixture envelopes through `PiRpcConnection` and verifies typed events and correlation. + +## Done criteria + +- [ ] Current RPC fixtures are checked in and sanitized. +- [ ] New commands/events parse and encode. +- [ ] `agent_settled` controls final run state. +- [ ] Canonical `contextUsage` and `max` are handled. +- [ ] Bridge remains transparent and conformance tests pass. +- [ ] Full Android and bridge gates pass. +- [ ] Plan row is DONE. + +## STOP conditions + +- Installed Pi docs differ materially from the features listed above. +- Supporting old and current Pi requires ambiguous lifecycle heuristics; stop and request a minimum-version decision. +- A new model would require widening nullability outside documented protocol fields. + +## Maintenance notes + +Protocol fixtures are compatibility tests, not snapshots of private sessions. Reviewers should scrutinize lifecycle ordering and ensure the bridge remains a pass-through. diff --git a/plans/004-simplify-tree-and-incremental-sync.md b/plans/004-simplify-tree-and-incremental-sync.md new file mode 100644 index 0000000..3cea2a7 --- /dev/null +++ b/plans/004-simplify-tree-and-incremental-sync.md @@ -0,0 +1,104 @@ +# Plan 004: Simplify tree handling and implement incremental synchronization + +> **Executor instructions**: Preserve control-lock and reconnect guarantees from ADR-0002/0003. Introduce new paths before deleting old ones. Update the plan index when done. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- bridge/src bridge/test core-rpc core-net app/src docs plans` + +## Status + +- **Priority**: P1 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/003-current-pi-rpc-conformance.md` +- **Category**: perf +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +Reconnect currently downloads all messages and active chat polls freshness every four seconds. Tree navigation tunnels a private slash command through extension status events even though current Pi exposes authoritative tree/entry reads. This plan adopts current read APIs and cursor synchronization while retaining the smallest bridge-specific navigation mechanism Pi still requires. + +## Current state + +- `PiRpcConnection.buildResyncSnapshot()` requests `get_state` then `get_messages`. +- `ChatViewModel` polls freshness every 4,000 ms and caps history locally. +- Bridge session indexer manually parses tree files. +- `pi-mobile-tree.ts` plus `server.ts` status waiters perform direct navigation because current RPC documentation still lacks a direct navigation command. +- ADR-0003 requires deterministic reconnect resync and drift protection; preserve the outcome, not necessarily polling. + +## Commands + +Run focused tests after each step, then: + +```bash +./gradlew ktlintCheck detekt test +(cd bridge && pnpm run check) +``` + +## Scope + +**In scope**: tree/entry RPC models added in plan 003; connection/controller/chat synchronization; bridge invalidation and remaining navigation endpoint; session indexer only where duplication can be safely removed; tests/docs; plan status. + +**Out of scope**: SDK migration, changing lock ownership, pagination UI, broad Chat UI decomposition, session-list removal. + +## Steps + +### Step 1: Characterize reconnect and tree behavior + +Add tests for initial full load, cursor catch-up, unknown cursor, active branch movement, reconnect during streaming, external session mutation, navigation changing leaf without appending, and control-lock denial. These tests must fail if messages are duplicated or lost. + +**Verify**: focused tests pass against current behavior except explicitly marked new cursor cases, which should fail before implementation. + +### Step 2: Use Pi `get_tree` for authoritative topology + +Route tree reads through the active Pi RPC runtime when a process exists and the caller owns control. Preserve bridge-owned read-only filesystem fallback only for inactive sessions/session browsing where no runtime exists. Keep the external Android tree model stable initially. Remove duplicate filtering only after proving each filter maps exactly; otherwise retain bridge presentation filtering over Pi entries. + +**Verify**: integration tests compare active-runtime tree responses with fixture topology, labels, and current leaf. + +### Step 3: Implement entry-cursor synchronization + +Store the last applied entry ID per active session. On resync, call `get_entries { since }`; append only returned entries and reconcile against returned `leafId`. If Pi rejects an unknown cursor or branch movement invalidates the local projection, perform one explicit full rebuild and reset the cursor. Initial session open may still perform a full fetch. + +Keep all cursor/rebuild decisions in the session/controller layer, not Compose. + +**Verify**: tests prove no full `get_messages` call on ordinary reconnect and exactly one fallback rebuild on invalid cursor. + +### Step 4: Replace periodic polling with invalidation plus safety fallback + +Have the bridge emit a bridge-channel session-invalidated event for mutations it observes from its Pi process and bridge imports/navigation. Android schedules cursor resync on that event. Retain low-frequency foreground safety polling only for external terminal edits the bridge cannot observe; choose/document a materially longer interval and pause it when app/chat is not active. + +**Verify**: fake-clock tests show no four-second continuous polling, immediate local invalidation resync, and eventual external-edit detection. + +### Step 5: Minimize private tree navigation plumbing + +Confirm from current Pi RPC docs that no direct navigation command exists. If absent, retain one bridge navigation message and the internal extension, but remove `get_commands` probing and user-command coupling where a deterministic internal load contract suffices. If a direct RPC navigation command now exists, replace the extension path and delete it with its tests/docs. Do not invent an undocumented command. + +**Verify**: navigation, cancellation, editor draft, leaf override, prompt-after-navigation, fork, reconnect, and failure tests pass. + +### Step 6: Measure and document + +Add deterministic counters or test instrumentation for full-history fetches, incremental entries, and freshness polls. Update performance docs with a reproducible large-session scenario and before/after payload counts; do not fabricate device timings. + +## Test plan + +Use existing `PiRpcConnectionTest`, `RpcSessionControllerTest`, Chat ViewModel tests, and bridge server/indexer tests. Add fake runtime fixtures rather than requiring a live model. Manual smoke: open old session, prompt, reconnect, edit same session in terminal, navigate branch, fork, and confirm exact timeline. + +## Done criteria + +- [ ] Active tree uses authoritative Pi topology. +- [ ] Ordinary reconnect uses entry cursors, not full history. +- [ ] Invalid cursors safely rebuild once. +- [ ] Four-second polling is removed. +- [ ] External edits remain eventually detectable. +- [ ] Private navigation mechanism is reduced to the actual remaining RPC gap. +- [ ] Full gates and manual smoke pass. +- [ ] Plan row is DONE. + +## STOP conditions + +- `get_entries` does not provide stable IDs/leaf semantics described by current Pi docs. +- Incremental entries cannot reconstruct the app timeline without private session-schema assumptions. +- Removing a bridge tree path would prevent browsing inactive sessions. + +## Maintenance notes + +A cursor is valid only relative to its session file. Review branch changes, compaction, import, and session replacement carefully; these are the paths most likely to require a full rebuild. diff --git a/plans/005-decompose-chat-architecture.md b/plans/005-decompose-chat-architecture.md new file mode 100644 index 0000000..5664e65 --- /dev/null +++ b/plans/005-decompose-chat-architecture.md @@ -0,0 +1,104 @@ +# Plan 005: Decompose chat state and UI without changing behavior + +> **Executor instructions**: This is a behavior-preserving refactor. Add characterization tests first, move one responsibility at a time, and keep each commit green. Update `plans/README.md` when done. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- app/src/main/java/com/ayagmar/pimobile/chat app/src/main/java/com/ayagmar/pimobile/sessions app/src/main/java/com/ayagmar/pimobile/ui/chat app/src/test plans` + +## Status + +- **Priority**: P2 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/003-current-pi-rpc-conformance.md` +- **Category**: tech-debt +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +`ChatScreen.kt` is about 3,900 lines, `ChatViewModel.kt` 3,252, and `RpcSessionController.kt` 1,459. A broad UX redesign on top of these files would increase coupling and regression risk. The goal is not a framework rewrite: create readable, feature-owned files while preserving public interfaces and behavior. + +## Current state + +- `ChatViewModel` owns timeline, streaming, commands, dialogs, tree, stats, models, bash, images, freshness, and diagnostics. +- `ChatScreen.kt` contains route wiring, headers, timeline rendering, composer, tool/thinking cards, sheets, dialogs, formatting, and constants. +- `RpcSessionController` mixes connection lifecycle, command requests, parsing, and recovery. +- Existing tests are strongest around ViewModel thinking/workflows and controller parsing; preserve them. +- Project style favors `StateFlow`, early returns, explicit models, small Compose components, and no DI framework. + +## Commands + +```bash +./gradlew :app:testDebugUnitTest :core-net:test :core-rpc:test +./gradlew ktlintCheck detekt test :app:lintDebug +``` + +## Scope + +**In scope**: the three oversized files, new feature-owned files in their existing packages, close tests, and plan status. + +**Out of scope**: new user-visible behavior, navigation redesign, dependency injection frameworks, new state-management libraries, protocol changes, broad design-system work. + +## Steps + +### Step 1: Freeze behavior + +Create a behavior inventory and add missing characterization tests for public ViewModel/controller actions and major UI state transitions: initial/empty, streaming, tool execution, retry, compaction, queue, extension dialogs, tree/model/stats/bash sheets, images, reconnect, and errors. Prefer state assertions over snapshots. + +**Verify**: all tests pass before production moves begin. + +### Step 2: Extract pure parsing and presentation logic + +Move JSON response mapping and stat/model/tree parsing out of `RpcSessionController` into package-local mapper files with focused tests. Move pure Chat formatting/presentation functions out of Compose screen. Do not add generic “utils” files; name files by domain. + +**Verify**: controller and UI tests pass; moved functions have direct tests. + +### Step 3: Split controller collaborators by existing responsibility + +Extract connection lifecycle/reconnect ownership and typed RPC request operations behind small package-local classes. Keep `SessionController` as the app-facing boundary. Do not introduce interfaces with only speculative reuse; interfaces are allowed only where existing tests already fake the boundary. + +**Verify**: public `SessionController` behavior and fake test utility remain compatible. + +### Step 4: Split ViewModel reducers/coordinators + +Keep one screen ViewModel but delegate pure state transitions to feature-specific reducers already suggested by data ownership: timeline/run state, extension UI, and auxiliary sheets. Avoid nested ViewModels and avoid widening nullability. Coroutines and side effects remain visible in the top-level coordinator or a narrowly named existing-service boundary. + +**Verify**: all characterization tests pass after each extraction. + +### Step 5: Split Compose by visible region + +Move route, top bar/status, timeline items, composer/run controls, and each overlay family into separate files. Keep state hoisted and callbacks explicit. Reuse `PiButton`, `PiCard`, `PiTextField`, `PiTopBar`, and theme tokens. Do not change dimensions/copy/layout in this plan. + +**Verify**: `:app:lintDebug`, unit tests, and existing androidTest compilation pass. + +### Step 6: Remove stale suppressions and dead code + +Remove suppressions no longer needed, unused constants/imports/parameters, and stale comments. Do not silence newly surfaced complexity warnings globally. + +**Verify**: `rg -n '@Suppress\("(LongMethod|TooManyFunctions)'` shows a material reduction in the three original files; full quality gate passes. + +## Test plan + +Match existing test styles. No screenshot baselines are required, but existing Compose tests must compile. Perform manual smoke against a fake or real bridge for prompt, stream, abort, steer, follow-up, tool expansion, extension dialog, tree, model, stats, bash, image, and reconnect. + +## Done criteria + +- [ ] Behavior inventory is covered by tests. +- [ ] No original file remains a multi-responsibility dumping ground; target under roughly 1,000 lines per file unless a reviewer-approved reason is recorded. +- [ ] No new framework/dependency was added. +- [ ] Public behavior and protocol are unchanged. +- [ ] Full gates and manual smoke pass. +- [ ] Plan row is DONE. + +## STOP conditions + +- A move requires a public protocol or UX change. +- Proposed extraction creates circular dependencies. +- Tests cannot distinguish existing behavior from accidental behavior; report the ambiguity before choosing. + +## Execution note + +The UI and pure mapping targets are met: visible regions, history parsing, RPC parsing, models, tree mapping, and formatting now live in feature-owned files around or below 1,000 lines. `ChatViewModel` remains about 2,800 lines and `RpcSessionController` about 1,250 lines because coroutine ordering and the public `SessionController` boundary remain centralized to preserve reconnect, streaming, and extension-event behavior. Further mechanical splitting would distribute side effects without establishing clearer ownership; reducer and mapper logic was extracted instead. This is the concrete exception to the approximate per-file target. + +## Maintenance notes + +Review for “file splitting without responsibility splitting.” The result should make a feature change local, not merely distribute the same global state across many files. diff --git a/plans/006-redesign-onboarding-and-navigation.md b/plans/006-redesign-onboarding-and-navigation.md new file mode 100644 index 0000000..dc7ee3f --- /dev/null +++ b/plans/006-redesign-onboarding-and-navigation.md @@ -0,0 +1,117 @@ +# Plan 006: Redesign onboarding, navigation, and recovery UX + +> **Executor instructions**: Build the smallest complete mobile journey using existing Compose/Material 3 components. Test state and navigation, then verify on device. Update `plans/README.md` when done. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- app/src docs README.md plans` + +## Status + +- **Priority**: P2 +- **Effort**: L +- **Risk**: MED +- **Depends on**: `plans/005-decompose-chat-architecture.md` +- **Category**: direction +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +First launch currently opens a raw Hosts CRUD screen and expects users to understand Tailscale, bridge binding, TLS, ports, and tokens. Hosts, Sessions, Chat, and Settings are peer drawer destinations even when they are unusable. The revived app needs a guided first success and clear recovery states. + +## Current state + +- `PiMobileApp.kt` starts on Hosts with no profiles, otherwise Sessions. +- Navigation is a modal drawer opened by a partially off-screen 34dp button and transparent scrim. +- Host editor has name/host/port/token/TLS fields with one generic error and a separate Test action. +- Diagnostics already distinguish network, auth, and RPC errors in `ConnectionDiagnostics`. +- Sessions screen supports host/cwd selection, search, new/resume, and active-session actions. +- Reuse project components and Material theme; do not introduce a design-system dependency. + +## Commands + +```bash +./gradlew :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:lintDebug +./gradlew ktlintCheck detekt test +``` + +## Scope + +**In scope**: app navigation, onboarding/host/session screens and ViewModels, reusable components/theme only as needed, tests, user docs, plan status. + +**Out of scope**: QR token transport unless it can be delivered without a new runtime dependency, bridge auth redesign, offline Pi, tablet-specific multi-pane UI, protocol changes. + +## Steps + +### Step 1: Define and test the journey + +Write a short product flow in `docs/onboarding.md`: + +1. Welcome and prerequisites. +2. Add bridge connection. +3. Test with staged results: network → auth → Pi/RPC readiness. +4. Save only after validation or explicitly allow “save and fix later.” +5. Choose recent/pinned project/session or create new. +6. Enter chat. + +Define recovery actions for no network, auth rejected, bridge incompatible, Pi missing, no model credentials, lock held, and session unavailable. + +**Verify**: document maps every state to one primary action. + +### Step 2: Implement first-run onboarding + +Create a dedicated onboarding route/state owner. Prefill safe defaults (port 8787, TLS based on selected connection guidance), explain where values come from, preserve token secrecy, validate fields inline, and combine test/save/continue into a clear sequence. Never display a stored token. + +Add back/cancel semantics and process-death-safe persistence for non-secret draft fields only if existing storage patterns support it simply. + +**Verify**: unit/Compose tests cover success and each recovery state. + +### Step 3: Replace global drawer hierarchy + +Use a simple adaptive top-level structure: + +- No configured host: onboarding only. +- Configured but no active session: Home/Sessions dashboard with host switcher and settings access. +- Active session: Chat as primary, with back/up to sessions and contextual actions. + +Use standard top app bars/navigation components with minimum 48dp touch targets and a normal scrim where modal navigation remains. Remove destinations that lead to unusable empty screens. + +**Verify**: navigation tests cover cold start, onboarding completion, resume, new session, back, host switch, and active chat restoration. + +### Step 4: Improve dashboard and empty states + +Make recent sessions the primary content. Add clear actions for “New session,” “Add another computer,” refresh/retry, and search. Use human-readable cwd labels while keeping full paths available in secondary detail. Empty/error/loading states must not be plain standalone text. + +**Verify**: Compose tests assert primary action availability for every state. + +### Step 5: Make errors actionable + +Map typed diagnostic/controller failures to concise title, explanation, and recovery action. Do not show raw exception text as primary copy; retain sanitized technical detail behind an expandable/copyable diagnostics action. Include lock owner guidance without exposing sensitive IDs unnecessarily. + +**Verify**: tests cover mapping and ensure token values never appear in rendered error text. + +### Step 6: Accessibility and device validation + +Check content descriptions, semantics, font scaling, contrast, 48dp targets, keyboard/IME behavior, and screen-reader order. Run existing androidTests plus manual validation at default and 1.3–1.5x font scale, portrait and landscape. + +## Test plan + +Add ViewModel tests for flow state and Compose tests for navigation/semantics. Manual device loop: clean install, failed network, bad token, valid bridge, no sessions, resume, create, reconnect, lock contention, rotate, background/restore. + +## Done criteria + +- [ ] Clean install reaches a guided setup, not CRUD. +- [ ] A successful test continues to sessions without drawer hunting. +- [ ] Top-level navigation reflects current app state. +- [ ] Every common failure has an actionable recovery. +- [ ] Stored tokens are never rendered or logged. +- [ ] Accessibility/device checklist passes. +- [ ] Full gates pass and plan row is DONE. + +## STOP conditions + +- UX requires new bridge protocol behavior; report and isolate it rather than improvising. +- Navigation rewrite would lose active-session restoration semantics. +- A new dependency is required for QR/pairing; defer that optional feature. + +## Maintenance notes + +Optimize for first successful prompt, not maximum configuration flexibility. QR pairing is a follow-up once the manual flow is secure and reliable. diff --git a/plans/007-modernize-android-and-release-dx.md b/plans/007-modernize-android-and-release-dx.md new file mode 100644 index 0000000..fa45061 --- /dev/null +++ b/plans/007-modernize-android-and-release-dx.md @@ -0,0 +1,136 @@ +# Plan 007: Modernize Android, CI, release checks, and contributor DX + +> **Executor instructions**: Upgrade in small compatible increments using authoritative release notes. Record every version choice and run full gates after each group. Update `plans/README.md` when done. +> +> **Drift check**: `git diff --stat ca7eaa2..HEAD -- build.gradle.kts settings.gradle.kts gradle app core-net core-rpc core-sessions benchmark .github README.md docs AGENTS.md plans` + +## Status + +- **Priority**: P2 +- **Effort**: L +- **Risk**: HIGH +- **Depends on**: `plans/004-simplify-tree-and-incremental-sync.md`, `plans/006-redesign-onboarding-and-navigation.md` +- **Category**: dx +- **Planned at**: commit `ca7eaa2`, 2026-07-12 + +## Why this matters + +The Android stack is centered on 2024-era Kotlin, Compose, AGP, target SDK 34, and deprecated `security-crypto` alpha storage. CI skips connected UI tests and release assembly. The final revival milestone should establish a current, reproducible baseline rather than leave upgrades to the next feature change. + +## Current state + +- Root: AGP 8.5.2, Kotlin 1.9.24, Gradle 8.7. +- App: compile/target SDK 34, Compose BOM 2024.06.00, Navigation 2.7.7, `security-crypto:1.1.0-alpha06`, release minification disabled. +- Token store uses deprecated `EncryptedSharedPreferences`/`MasterKey`. +- CI runs `./gradlew :app:check`; it does not run connected tests or assemble release. +- Repository has no project `AGENTS.md`, version catalog, `.env.example` before plan 002, or single root verification command. + +## Commands + +Use project-supported JDK from current authoritative Android requirements. Final commands: + +```bash +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) +``` + +CI emulator command must run the small existing `androidTest` suite and exit 0. + +## Scope + +**In scope**: Gradle wrapper/build files and dependency organization, Android source/tests required by migrations, benchmark compatibility, CI, root contributor docs/scripts, README/testing docs, plan status. + +**Out of scope**: unrelated dependency additions, Play Store publication, signing secrets, changing minSdk without explicit evidence, enabling minification without release smoke coverage. + +## Steps + +### Step 1: Research and record a compatible target matrix + +Using official Android, Kotlin, Compose, and library release notes/registries, select latest stable mutually compatible versions as of execution time. Record current→target, compatibility rationale, migration links, and expected code impact in `docs/dependency-matrix.md`. Include Node/pnpm/Pi supported versions from earlier plans. + +**Verify**: every upgraded dependency has an authoritative source and no alpha/beta choice unless unavoidable and justified. + +### Step 2: Upgrade build foundation incrementally + +Upgrade Gradle/AGP/JDK/compileSdk-targetSdk first, then Kotlin/Compose compiler integration, then AndroidX/Compose BOM and other libraries. Prefer a version catalog if it clearly reduces existing duplication; do not introduce convention plugins. Run compile/test/lint after each group and commit separately. + +**Verify**: final full Gradle command passes without suppressing new warnings globally or expanding lint baseline. + +### Step 3: Replace deprecated token storage safely + +Design the smallest supported Android Keystore-backed storage path. Preserve existing encrypted tokens through an explicit one-time migration if technically possible. If reliable migration is impossible, STOP and request a product decision because silently losing tokens harms onboarding. Never log tokens; disable backup for secret material or add explicit backup exclusion rules as appropriate. + +Tests cover new write/read/delete, migration, corrupted legacy data, and no plaintext storage. + +**Verify**: `rg -n 'EncryptedSharedPreferences|security-crypto' app build.gradle.kts` returns no production matches after migration; security tests pass. + +### Step 4: Strengthen CI + +Add jobs/steps for: + +- root Kotlin lint/detekt/unit tests across all modules, +- debug lint and debug/release assembly, +- bridge check and production audit, +- connected Android tests on a pinned emulator API with caching and timeout, +- artifact upload for debug APK on appropriate events if repository policy permits. + +Use least-privilege permissions. Do not add signing credentials. + +**Verify**: validate workflow syntax and run equivalent commands locally; all pass. + +### Step 5: Add contributor and agent guidance + +Create project `AGENTS.md` containing architecture invariants, exact setup/check commands, module boundaries, RPC compatibility policy, test placement, secret policy, and plan execution protocol. Add a root `Makefile` or simple checked-in script only if it genuinely provides one command for all checks; do not add a task framework dependency. + +Update README/testing docs for clean checkout through first prompt and release verification. + +**Verify**: a clean-shell command sequence in docs matches CI exactly. + +### Step 6: Final end-to-end acceptance + +Create `docs/revival-acceptance.md` with executable checks and measured results. Run on a real emulator/device and bridge: + +- clean install/onboarding, +- connection failures and success, +- list/new/resume/rename/fork/tree/import/export/compact, +- prompt/image/stream/tool/thinking/abort/steer/follow-up, +- extension dialogs/widgets/status, +- retry/compaction/settled lifecycle, +- disconnect/reconnect/incremental resync/external edit, +- multiple hosts and lock contention, +- background/rotation/font scale, +- release APK launch. + +Record actual device/API/Pi/bridge versions and pass/fail. Fix failures within the owning prior-plan architecture; do not mark done with TODO/TBD values. + +## Test plan + +All unit, lint, bridge, connected Compose, debug/release build, and manual acceptance gates are mandatory. Add regression tests for every migration bug discovered. + +## Done criteria + +- [ ] Stable compatible dependency matrix is documented. +- [ ] Android builds on a current supported toolchain and target SDK. +- [ ] Deprecated security-crypto storage is removed with safe migration. +- [ ] CI exercises unit, static, bridge audit, release, and connected tests. +- [ ] Project `AGENTS.md` and clean-checkout instructions exist. +- [ ] Revival acceptance has no unresolved failures/TBDs. +- [ ] Global completion gate in `plans/README.md` passes. +- [ ] Plan row is DONE. + +## STOP conditions + +- Latest stable versions are mutually incompatible; report the conflict and propose a pinned compatible matrix. +- Token migration cannot preserve credentials safely. +- Release build requires signing material not available in repository-safe configuration. +- Connected tests are flaky after two root-cause attempts; report evidence instead of hiding or retry-looping them. + +## Execution note + +The operator approved resetting legacy encrypted tokens because removing `security-crypto` makes its private storage format unavailable. Host profiles are preserved, only the legacy token preference file is removed once, and the app shows a restrained token re-entry notice. New tokens use platform Android Keystore AES-256-GCM. + +Connected and manual device checks are `PENDING — operator-owned` under the superseding roadmap instruction. The complete executable procedure and evidence fields are in `docs/revival-acceptance.md`. + +## Maintenance notes + +Do not combine future framework upgrades with feature work. Keep the dependency matrix and CI emulator version current, and treat expanded lint baselines or skipped tests as regressions requiring explicit review. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..fbd85bd --- /dev/null +++ b/plans/README.md @@ -0,0 +1,45 @@ +# Pi Mobile Revival Implementation Plans + +Generated by the improve skill on 2026-07-12 at commit `ca7eaa2`. Execute the plans in order. Each executor must read the full plan before editing, run every verification gate, obey STOP conditions, and update the status row before ending the session. + +These plans deliberately retain the current architecture: Android connects to a network-facing bridge over authenticated WebSocket, and the bridge owns one isolated `pi --mode rpc` subprocess per cwd. Migrating the bridge to the Pi SDK is not part of this roadmap. + +## Execution order and status + +| Plan | Title | Priority | Effort | Depends on | Status | +|---|---|---:|---:|---|---| +| [001](001-clean-stale-documentation.md) | Clean stale documentation and record the RPC decision | P1 | S | — | DONE | +| [002](002-secure-and-pin-the-bridge.md) | Secure, pin, and bound the bridge runtime | P1 | M | 001 | DONE | +| [003](003-current-pi-rpc-conformance.md) | Bring the client and bridge up to the current Pi RPC contract | P1 | L | 002 | DONE | +| [004](004-simplify-tree-and-incremental-sync.md) | Simplify tree handling and implement incremental synchronization | P1 | L | 003 | DONE | +| [005](005-decompose-chat-architecture.md) | Decompose chat state and UI without changing behavior | P2 | L | 003 | DONE | +| [006](006-redesign-onboarding-and-navigation.md) | Redesign onboarding, navigation, and recovery UX | P2 | L | 005 | DONE (device acceptance pending — operator-owned) | +| [007](007-modernize-android-and-release-dx.md) | Modernize Android, CI, release checks, and contributor DX | P2 | L | 004, 006 | DONE (device acceptance pending — operator-owned) | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (reason) | REJECTED (reason) + +## Dependency notes + +- 001 removes contradictory direction before any fresh executor acts on it. +- 002 establishes a safe network and dependency baseline before expanding protocol coverage. +- 003 creates a current RPC compatibility baseline. Plans 004 and 005 must not begin without it. +- 004 changes synchronization semantics and should remain separate from the UI/state decomposition in 005. +- 006 depends on 005 so the UX redesign is not added to the current 3,900-line screen and 3,252-line ViewModel. +- 007 runs last because framework upgrades mixed with feature work make failures difficult to diagnose. + +## Global completion gate + +After all plans are DONE, run from the repository root: + +```bash +./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease +(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod) +``` + +Expected: every command exits 0, bridge audit reports no high vulnerabilities, and both APKs are produced. Then execute the manual end-to-end checklist added by plan 007 on an Android device or emulator against a real bridge and Pi installation. + +## Findings considered and rejected + +- Replace the subprocess bridge with the Pi SDK: rejected for this roadmap. RPC preserves process isolation, directly consumes Pi's supported cross-language contract, and avoids rebuilding Pi's RPC dispatcher inside the bridge. +- Remove the bridge: rejected. Android cannot directly consume stdio RPC, and the bridge owns authentication, WebSocket transport, remote session discovery, cwd locks, and reconnect policy. +- Add offline agent execution on Android: rejected for now; it conflicts with the product's remote-control purpose and would substantially expand credential, filesystem, and runtime scope.