diff --git a/.claude/skills/stage-chapters/SKILL.md b/.claude/skills/stage-chapters/SKILL.md new file mode 100644 index 00000000..27a054d4 --- /dev/null +++ b/.claude/skills/stage-chapters/SKILL.md @@ -0,0 +1,337 @@ +--- +name: stage-chapters +description: Generate Stage chapters for the current local git branch and open them in a browser for review. +user-invocable: true +--- + +# stage-chapters + +Generates a Stage chapter run for the current local git branch and opens it in a browser. Uses `stagereview prep` to compute the diff, then generates chapters and a prologue, and hands the result to `stagereview show` to launch the SPA. + +## Prerequisites + +Run these checks before any other work. If either fails, stop with the error message — do not continue. + +1. **`stagereview` is installed.** Run `which stagereview`. If it exits non-zero, instruct the user: + + ``` + stagereview is not installed. Run: + + npm install -g stagereview + + Then retry /stage-chapters. + ``` + + Stop. + +2. **The current directory is a git repo.** Run `git rev-parse --is-inside-work-tree`. If it does not print `true`, stop with: + + ``` + /stage-chapters must be run inside a git repository. + ``` + +## Step 1 — Run prep + +```bash +PREP_FILE=$(stagereview prep) +``` + +`stagereview prep` auto-detects the base ref (main/master), computes the merge-base, generates the diff, filters out lockfiles/binaries, and formats hunks with line numbers for analysis. By default it auto-detects the diff scope: if uncommitted changes are present the diff includes staged, unstaged, and untracked files; otherwise it uses the committed branch diff. It writes a plain-text file and prints only the file path to stdout. + +`prep` and `show` also accept positional git refs: + +```bash +PREP_FILE=$(stagereview prep main) +PREP_FILE=$(stagereview prep main feature) +PREP_FILE=$(stagereview prep main..feature) +PREP_FILE=$(stagereview prep main...feature) +``` + +Use the same positional refs for `show`: + +```bash +stagereview show "$AGENT_OUTPUT" main..feature +``` + +Both `prep` and `show` accept these optional flags: + +- **`--base `** — base ref to diff against (default: auto-detect main/master). +- **`--compare `** — compare ref to diff against `--base`. +- **`--ref `** — diff scope. One of: + - `work` — staged + unstaged + untracked changes (full working tree vs merge-base). + - `staged` — only staged changes (index vs HEAD). + - `unstaged` — only unstaged changes (working tree vs index). + - Omitted — auto-detect (equivalent to `work` when uncommitted changes exist, committed branch diff otherwise). +- **`--pr `** — review a GitHub pull request instead of the local branch. The base/head come from the PR itself, and its commits are fetched locally. Cannot be combined with positional refs, `--base`, `--compare`, or `--ref`. Requires `gh` to be installed and authenticated, and a github.com `origin` remote. Useful for reviewing a teammate's PR you don't have checked out. + +When flags or positional refs are specified, pass the same scope to **both** `prep` and `show`: + +```bash +PREP_FILE=$(stagereview prep --base feature-a --ref staged) +# ... later ... +stagereview show --base feature-a --ref staged "$AGENT_OUTPUT" + +PREP_FILE=$(stagereview prep --base main --compare feature) +# ... later ... +stagereview show --base main --compare feature "$AGENT_OUTPUT" + +# Review a GitHub PR by number or URL +PREP_FILE=$(stagereview prep --pr 123) +# ... later ... +stagereview show --pr 123 "$AGENT_OUTPUT" +``` + +If `prep` exits non-zero, relay its stderr to the user and stop. + +**Do not modify files in the working tree between running `prep` and running `show`.** Both commands independently snapshot the git state. If the diff changes between them, `show` will reject the chapters with a hunk coverage error because the hunks no longer match. + +## Step 2 — Read prep output + +Read `$PREP_FILE` via the Read tool (or equivalent). For large diffs, use the Read tool's `offset` and `limit` parameters to read in chunks. + +The file has two sections separated by headers: + +1. **`=== COMMIT MESSAGES ===`** — `git log --oneline` output for prologue context. +2. **`=== HUNKS ===`** — formatted diff hunks with line numbers. Each hunk looks like: + +``` +=== File: src/app.ts (modified) | filePath: "src/app.ts", oldStart: 1 === +=== Hunk @1: @@ -1,5 +1,6 @@ === +1 1 | const a = 1; +2 |-const b = 2; + 2 |+const b = 3; + 3 |+const c = 4; +3 4 | const d = 5; +``` + +The two number columns are the **old line number** (left) and **new line number** (right). A blank column means the line doesn't exist on that side — additions have no old line number, deletions have no new line number. These numbers are used directly for `lineRefs` in key changes (see Step 3d). + +`commits.txt` contains `git log --oneline` output for prologue context. + +## Step 3 — Cluster + narrate + +Using the hunks from `hunks.txt`, produce a `chapters` array. Each chapter groups related hunks into a coherent story beat, narrates them for a reviewer unfamiliar with this part of the codebase, and flags judgment calls that need human input. + +### 3a — Clustering rules + +Group hunks by **causal relationship** — changes that set up or enable later changes belong together. + +- Spanning multiple files is expected and correct (e.g., schema + API + UI for one feature = one chapter). +- Moves and refactors are a single chapter — when code is removed from one file and added to another (or a file is deleted and a similar one created), group the deletion and addition hunks together as one "Move/Refactor" chapter, not separate "Remove" and "Add" chapters. +- Split only when changes are truly independent — a reviewer could understand one without knowing about the other. +- Tests belong with their implementation chapter. +- Config/dependency changes can be their own chapter if unrelated to a feature chapter. + +**Chapter ordering:** + +1. Foundation first: types, interfaces, schemas, utilities that others depend on +2. Core logic next: main implementation +3. Integration last: wiring, configuration, tests + +Consider symbol dependencies between chapters — a chapter that introduces a type another chapter uses must come first. + +**Hunk ordering within a chapter:** + +- Group all hunks from the same file together — do not interleave hunks from different files. +- Within the same file, list hunks in ascending `oldStart` order (matching file layout). + +### 3b — Self-validation rules + +Every hunk in the formatted diff **must** appear in exactly one chapter. No hunk may be omitted and no hunk may appear in more than one chapter. + +Each hunk header in the prep output has the format: +``` +=== File: () | filePath: "", oldStart: === +``` + +Use the `filePath` and `oldStart` values from these headers to build `hunkRefs`. + +`stagereview show` validates hunk coverage automatically — it will error with a list of missing or extra hunks if the chapters don't account for every hunk in the diff. If this happens, fix the chapters and retry. + +### 3c — Narration rules + +Write each chapter as a story beat — a meaningful step that moves the branch forward, not a summary of files changed. + +- **Title:** action-oriented verb phrase, max 8 words (e.g., "Wire org ID through the API layer"). No filler like "Add support for". +- **Summary:** 2–3 sentences covering what this chapter enables and why. Lead with impact, then connect to the broader purpose. When a chapter builds on a previous one, open with that causal link explicitly (e.g., "Now that X is in place…"). + - Keep paragraphs short. Prefer splitting distinct points into separate short paragraphs (separated by a blank line) rather than writing one long dense paragraph. Each paragraph should convey a single idea. + - Markdown allowed: `**bold**` for emphasis, `*italics*` for nuance, `` `backticks` `` for inline code references, and fenced code blocks when a short snippet (≤ 6 lines) helps illustrate the change. + +### 3d — Key change rules + +Key changes are **judgment calls only a human reviewer can make** — things that require product context, team conventions, or knowledge of the author's intent. Linters, type checkers, and code-review bots already cover correctness and style; skip anything they can catch. Ignore auto-generated files. + +Return an **empty array** when nothing needs human input — do **not** invent items to fill the list. When a chapter is a straightforward rename, type fix, or mechanical refactor with no judgment calls, `keyChanges` should be `[]`. + +Frame each item as a **question**. + +Each key change includes `lineRefs`: one line range per distinct spot the question depends on. Most questions touch a single location, so use one range; only add more when the judgment genuinely spans related code in different places. + +**Reading line numbers from `hunks.txt`:** Each diff line shows two number columns — old (left) and new (right). Use these numbers directly: +- For `side: "deletions"` — use the **old** (left) column number as `startLine`/`endLine`. +- For `side: "additions"` — use the **new** (right) column number as `startLine`/`endLine`. +- Do **not** count lines yourself — read the numbers from the formatted output. + +Keep ranges tight — point to the specific lines the question is about, not the entire hunk. `startLine` and `endLine` must both be positive integers with `endLine >= startLine`. + +**Good examples:** + +- "Should `retryCount` reset when the user switches orgs?" +- "Is a 60-minute session timeout appropriate for this user base, or would 30 minutes be safer?" +- "Does this new index cover the query patterns the team actually uses in production?" + +**Bad examples:** + +- "Check that the auth logic is correct." — vague, verifiable by reading the code +- "The function now handles errors." — changelog item, not a question +- "Make sure the tests pass." — CI catches this, not a human judgment call + +### 3e — Output format + +Produce an array of chapter objects. Each chapter: + +```jsonc +{ + "id": "chapter-1", // unique within the run, e.g. "chapter-1", "chapter-2", … + "order": 1, // positive integer, 1-indexed + "title": "Short imperative title", + "summary": "Why this chapter matters to the reviewer.", + "hunkRefs": [ + // one entry per hunk in the chapter + { "filePath": "path/to/file.ts", "oldStart": 42 } + ], + "keyChanges": [ + // zero or more judgment-call questions + { + "content": "A judgment-call question for the reviewer.", + "lineRefs": [ + { + "filePath": "path/to/file.ts", + "side": "additions", + "startLine": 50, + "endLine": 55 + } + ] + } + ] +} +``` + +- Do **not** invent `hunkRefs` — only use `(filePath, oldStart)` tuples that actually appear in the formatted hunks. +- `keyChanges[].lineRefs` must have at least one entry per key change. + +## Step 4 — Generate prologue + +After building the chapters, generate a **prologue** — a high-level overview of the entire change. The prologue helps reviewers orient themselves before diving into individual chapters. + +Use `commits.txt` from the prep output for context. + +Using the diff, chapters, and commit messages, produce a `prologue` object with the following fields: + +### motivation (string or null) + +One sentence a non-engineer would understand. What was broken, annoying, or missing — from a person's perspective. If the commit messages are generic and the diff doesn't make the motivation obvious, use `null`. + +**Good:** "Dashboards would break during deploys, so people had to keep refreshing until things came back." +**Bad:** "The API client had no retry logic for 503 errors." (too technical — no one outside the team knows what that means) + +### outcome (string or null) + +One sentence a non-engineer would understand. What's better now. Same null rule as motivation. + +**Good:** "Dashboards stay up during deploys now." +**Bad:** "Added exponential backoff with a base delay of 100ms." (implementation detail) + +### diagram (string or null) + +A Mermaid diagram source string (**without** fenced code block markers) that gives a reviewer the big picture at a glance. Set this only when the change spans multiple components in a data or control flow — e.g. a new endpoint wiring through middleware to a database, a state machine gaining transitions, or an event pipeline connecting producers to consumers. + +Return `null` for single-file changes, renames, config updates, test-only changes, dependency bumps, or anything where the key changes alone are clear. **Most changes should NOT have a diagram.** + +Diagram type guide: +- `graph TD` or `graph LR` for data flow, component wiring, module dependencies +- `sequenceDiagram` for request/response or call chains across layers +- `stateDiagram-v2` for lifecycle or state machine changes + +Keep diagrams concise — under 10 nodes. They render in a narrow side panel. Quote node labels that contain special characters (`@ # < >`): e.g. `A["@scope/package"]`, not `A[@scope/package]`. + +### keyChanges (array of 2–5 objects) + +Each object has: +- `summary`: 6–10 words describing what's different now. **Outcome-focused**, not action-focused. +- `description`: Capitalized sentence, 10–15 words of additional context. + +**Good:** `summary: "Audit runs are now tracked in a database"`, `description: "Uses new Drizzle ORM schema with full history retention"` +**Bad:** `summary: "Adds Drizzle ORM layer"` (action-focused — describe what changed, not what you did) + +### focusAreas (array of 1–5 objects) + +Always provide at least 1 focus area. Even clean changes have spots worth a reviewer's attention. + +Each object has: +- `type`: one of `security`, `breaking-change`, `high-complexity`, `data-integrity`, `new-pattern`, `architecture`, `performance`, `testing-gap` +- `severity`: one of `critical`, `high`, `medium` (for problems) or `info` (for points of interest) +- `title`: 3–5 word noun phrase (e.g., "Unvalidated user input") +- `description`: WHY this was flagged + a declarative action for the reviewer. Use "confirm", "verify", or "check" to give the reviewer a specific task. +- `locations`: array of file paths where this applies + +**Good:** `type: "security", severity: "high", title: "Unvalidated user input", description: "User-provided ID passed directly to database query — confirm input is validated and parameterized"` +**Bad:** `description: "Worth understanding"` (no action, vague) + +### complexity + +Object with: +- `level`: one of `low`, `medium`, `high`, `very-high` +- `reasoning`: brief explanation (e.g., "New DB schema plus multiple service changes") + +### Style + +Talk like a coworker, not a changelog. No jargon, no filler phrases, no "this change introduces/implements/adds". Just say what happened and why it matters. + +## Step 5 — Write agent output + +Compute a unique temp path and write the JSON via a bash heredoc: + +```bash +AGENT_OUTPUT=$(mktemp "${TMPDIR:-/tmp}/stage-agent-output.XXXXXX") +cat > "$AGENT_OUTPUT" << 'AGENT_EOF' +{ + "chapters": [ ... ], + "prologue": { ... } +} +AGENT_EOF +``` + +The trailing `XXXXXX` (with no suffix after) is required by macOS BSD `mktemp`. Using `cat` with a heredoc avoids tool-specific file-writing issues. + +Field rules: + +| Field | Constraint | +|-------|------------| +| `chapters[].id` | Non-empty, unique within the run | +| `chapters[].order` | Positive integer (1-indexed) | +| `chapters[].hunkRefs[].oldStart` | Non-negative integer — the pre-image start line from the `oldStart` in the formatted hunk header (`0` for new files) | +| `chapters[].keyChanges[].lineRefs` | Array with at least one entry | +| `lineRefs[].side` | `"additions"` (right side) or `"deletions"` (left side) | +| `lineRefs[].startLine` / `endLine` | Positive integers; `endLine >= startLine` | +| `prologue` | Optional object; omit entirely if not desired | +| `prologue.motivation` | String or `null` | +| `prologue.outcome` | String or `null` | +| `prologue.diagram` | Mermaid source string (no code fences) or `null`; omit for most changes | +| `prologue.keyChanges` | Array of 2–5 objects with `summary` and `description` | +| `prologue.focusAreas` | Array of 1–5 objects | +| `prologue.focusAreas[].type` | One of: `security`, `breaking-change`, `high-complexity`, `data-integrity`, `new-pattern`, `architecture`, `performance`, `testing-gap` | +| `prologue.focusAreas[].severity` | One of: `critical`, `high`, `medium`, `info` | +| `prologue.complexity.level` | One of: `low`, `medium`, `high`, `very-high` | + +## Step 6 — Display generated chapters + +Hand the file to `stagereview`: + +```bash +stagereview show "$AGENT_OUTPUT" +``` + +`stagereview show` auto-detects the agent output format, independently computes the scope and "Other changes" chapter for filtered files, validates the JSON, inserts the run into the local SQLite database, boots a loopback HTTP server, and opens the browser. + +**The command blocks until the user presses Ctrl+C.** If your harness requires non-blocking execution, run it in the background (e.g., `run_in_background` in Claude Code). Invoke it as the final command in the workflow. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1630f1c8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,140 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +P4OC (Pocket for OpenCode) is an Android client for [OpenCode](https://github.com/sst/opencode), a terminal-based AI coding assistant. The app connects to a running OpenCode server over HTTP/SSE/WebSocket and lets you chat, browse/edit files, view diffs, run a terminal, and manage sessions from a phone. Package: `dev.blazelight.p4oc`. + +The UI is a deliberate terminal aesthetic: flat, monospaced where it matters, 0dp corners everywhere, no stock Material3 look. This is a hard styling constraint, not a preference — see "Theme system" below. + +## Build & test commands + +Always set `JAVA_HOME` first (Java 17 required): + +```bash +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk +``` + +```bash +./gradlew :app:compileDebugKotlin # fast compile check — use this while iterating +./gradlew :app:assembleDebug # debug APK -> app/build/outputs/apk/debug/ +./gradlew :app:testDebugUnitTest # unit tests (JVM, no device) +./gradlew :app:detekt # static analysis +./gradlew installDebug # build + install on connected device +``` + +Run a single unit test class or method: + +```bash +./gradlew :app:testDebugUnitTest --tests "dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest" +./gradlew :app:testDebugUnitTest --tests "*.ChatViewModelTest.someTestMethod" +``` + +Instrumented tests (requires a connected device/emulator): + +```bash +./gradlew :app:connectedDebugAndroidTest +./gradlew :app:connectedDebugAndroidTest --tests "*.ConnectSmokeTest" +``` + +Theme-convention lint (checks for `MaterialTheme.colorScheme` usage, raw M3 dialogs, hardcoded `RoundedCornerShape` outside `Theme.kt`): + +```bash +./scripts/check_theme_violations.sh +``` + +Detekt uses `detekt.yml` + `app/detekt-baseline.xml`. Fix or ticket new findings rather than deleting framework-, Compose-, serialization-, reflection-, or resource-referenced code just to silence it. + +Release builds (`assembleRelease`, `assembleGithubRelease`) need signing config in `local.properties` (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) — see README for details. Don't attempt these without that config present. + +Debug package id is `dev.blazelight.p4oc.debug` (applicationIdSuffix `.debug`) — account for this in `adb`, deep links, and any device inspection. + +`Makefile` has local dev shortcuts (`make run`, `make serve`, `make logcat`) — machine-specific, not the canonical build path. + +## Architecture + +MVVM + clean architecture layers under `app/src/main/java/dev/blazelight/p4oc/`: + +``` +core/ Network layer (Retrofit/OkHttp), SSE (LaunchDarkly EventSource), DataStore, connection management +data/ DTOs, mappers (DTO -> domain), repository implementations +di/ Koin modules (single KoinModules.kt — no codegen, chosen specifically for AGP 9 + Kotlin 2.3 compat) +domain/ Domain models, repository interfaces, workspace/session/server identity types +terminal/ Termux terminal-emulator/terminal-view integration +ui/ + components/ Shared TUI widgets, markdown renderer, code blocks, tool-call widgets + navigation/ NavGraph, route definitions/encoding + screens/ chat, sessions, projects, settings, terminal, files, diff, setup, server + tabs/ Multi-tab shell (TabManager, TabState, TabNavHost, TabBar) + theme/ Theme system (SemanticColors, Spacing, Sizing, Typography, Motion) + workspace/ WorkspaceViewModel / WorkspaceRepositoryOwner — per-tab scoped state +``` + +### Workspace/tab model — the load-bearing architecture + +The app is multi-tab; each tab owns an independently-scoped **workspace** (a server + directory pair). This replaced an earlier "current directory" global-state design, and the old design's bugs are exactly what the current rules exist to prevent. Read `docs/design-locks/README.md` and its linked lock docs before touching session, workspace, routing, or SSE-event code — they contain the worked examples and are the source of truth over any summary here. + +Cross-cutting invariants: + +- **No ambient/global context.** No global mutable `currentWorkspace`/`currentSession`/`currentDirectory` anywhere, in any layer. +- **Server is the source of truth.** Local state is a cache; on conflict, server state wins. +- **Identity is explicit and typed.** `ServerRef`, `Workspace`, `WorkspaceSession`, `SessionId`, `RelativePath`, `WorkspacePath` (see `domain/workspace/`, `domain/session/`, `domain/server/`) are the only context primitives — don't invent stringly-typed identity. +- **A `WorkspaceViewModel` owns the lifetime of one tab.** Closing the tab disposes everything scoped to it. No leak-by-key-eviction caches. +- **Repositories are constructed with a workspace-scoped client**, never by reaching into active-tab state from the data layer. +- **Deep links from before the cutover are rejected, not best-effort migrated.** + +`AGENTS.md` (also loaded automatically) enumerates ten concrete forbidden patterns with bad/good code pairs (no default/global `Workspace`, no nullable `directory` defaults on API methods, no directory fallback chains, no mutable workspace vars, no `CurrentWorkspace` singleton, no parallel chat message buffers outside `SessionRepositoryImpl`, no global API variants, no nullable `withWorkspace` escape hatches, etc.). Treat that list as binding when writing anything that touches workspace/session/directory routing. + +Key source locations (from `AGENTS.md`): + +| What | Where | +|------|-------| +| Domain models | `domain/model/` | +| API interface | `core/network/OpenCodeApi.kt` | +| SSE events | `core/network/OpenCodeEventSource.kt` | +| DTOs | `data/remote/dto/` | +| Mappers | `data/remote/mapper/Mappers.kt` | +| Chat UI | `ui/screens/chat/` | +| Terminal | `ui/screens/terminal/` + `terminal/` | +| Theme system | `ui/theme/` | + +### Networking + +Retrofit + OkHttp for REST. SSE via LaunchDarkly's `okhttp-eventsource` for streaming chat events (`OpenCodeEventSource.kt`). A separate WebSocket path drives the embedded PTY/terminal (reconnects with exponential backoff — see `TESTING.md` for the manual verification steps and expected logcat tags `OpenCodeEventSource`, `PtyWebSocket`, `ConnectionManager`). + +### Theme system + +Not Material3 theming — a custom system loaded from JSON files in OpenCode's own theme format. ~50 semantic color tokens exposed via `LocalOpenCodeTheme.current`, plus `Spacing.*`, `Sizing.*`, `TuiShapes` (all corners 0dp), `Motion.*`. Bundled themes: catppuccin (+frappe/macchiato), dracula, gruvbox, nord, opencode, tokyonight, xterm. + +Rules (enforced by `scripts/check_theme_violations.sh`, not just convention): + +- Colors: `LocalOpenCodeTheme.current`, never `MaterialTheme.colorScheme`. +- `MaterialTheme.typography` is fine to use — it *is* the custom typography. +- Dimensions: `Spacing.*` / `Sizing.*` tokens, never hardcoded `.dp`. +- Shapes: `TuiShapes` only. +- No stock Material3 widgets, rounded corners, or elevation shadows unless there is genuinely no alternative. + +### Interaction/accessibility conventions + +- `role = Role.Button` / `Role.Tab` on actionable `.clickable` modifiers. +- Meaningful `contentDescription` on functional (non-decorative) icons. +- `Modifier.testTag(...)` on key interactive elements — `TESTING.md` has the current tag inventory (`tab_bar`, `chat_input`, `send_button`, etc.) for UI-automation coverage. + +### Status-dot semantics + +One consistent status vocabulary is used across tabs/sessions/sub-agents/chat/files/settings — prefer a centralized mapping over scattered raw `Text("●")` glyphs. States: connected/idle, running/busy, awaiting input, retrying/reconnecting, error, background/cold, dirty/unsaved. No fake progress percentages for agent work — use real run state (spinner/pulse/text). Update the status legend in Settings → Help if you change these semantics. + +### Agent-space UI rule + +The chat/agent/code workspace is the app's core value; screen space on phones is precious. Justify any persistent UI chrome heavily — prefer contextual/transient/collapsible/overflow UI, put row actions in long-press/overflow menus, put creation actions in headers/overflow/empty-states rather than floating persistent controls, and keep slash/autocomplete popups from covering the typed command or cursor. + +### In-app editing + +File viewing/editing stays tabbed inside the app (Sora Editor). Don't add intent-out-to-external-editor flows for core editing — it breaks the tabbed workspace model. External editor interop, if ever added, is optional import/export only, not the primary path. + +## Tooling in this repo + +- **Ticket tracking**: this project uses `tk`. Run `tk ready` to find available work, `tk show ` for details, `tk start ` / `tk close ` to claim/complete. Tickets live under `.tickets/`. When writing/updating tickets, include full acceptance criteria up front (no "phase 1"/partial-delivery language unless explicitly a spike) — see the "Ticket Quality" section of `AGENTS.md` for the expected shape (Problem/Evidence/UX Constraint/Expected Behavior/Acceptance Criteria/Verification). +- **OpenSpec**: for proposals, new capabilities, breaking changes, or architecture shifts, consult `openspec/AGENTS.md` first rather than coding directly — that's the authoritative spec/proposal process for this repo. +- **Design locks** (`docs/design-locks/`): six locked decisions (A–F) covering deep links/prompt modality, SSE hydrate-race semantics, mutation-on-failure contract, server identity, route encoding, and SSE event routing. These are binding on downstream work; revisiting one means reopening its design ticket, not silently diverging in code. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2a10d053 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# Local dev convenience — not part of the project's standard build (see mise.toml). +# Machine-specific paths below; adjust if yours differ. + +JAVA_HOME := /usr/lib/jvm/java-17-openjdk-amd64 +ANDROID_HOME ?= /home/melloss/Android/Sdk +APP_ID := dev.blazelight.p4oc.debug +MAIN_ACTIVITY := dev.blazelight.p4oc.MainActivity +PORT ?= 4096 +PORT2 ?= 4097 + + + +export JAVA_HOME +export ANDROID_HOME + +.DEFAULT_GOAL := run + +.PHONY: run install build uninstall logcat serve clean + +build: + ./gradlew :app:assembleDebug + +install: + ./gradlew :app:installDebug + +run: install + adb shell am start -n $(APP_ID)/$(MAIN_ACTIVITY) + +uninstall: + adb uninstall $(APP_ID) + +logcat: + adb logcat --pid=$$(adb shell pidof $(APP_ID)) + +# Starts the opencode server and forwards it over USB (adb reverse) so the +# device can reach it at http://127.0.0.1:$(PORT) regardless of Wi-Fi. +serve: + adb reverse tcp:$(PORT) tcp:$(PORT) + opencode serve --hostname 0.0.0.0 --port $(PORT) + +serve2: + adb reverse tcp:$(PORT2) tcp:$(PORT2) + opencode serve --hostname 0.0.0.0 --port $(PORT2) + +clean: + ./gradlew clean diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 461c009d..8d4c336e 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -25,7 +25,7 @@ CyclomaticComplexMethod:Mappers.kt$PartMapper$fun mapToDomain(dto: PartDto): Part CyclomaticComplexMethod:Mappers.kt$PartMapper$private fun mapFileSourceToDomain(source: JsonObject): FilePartSource? CyclomaticComplexMethod:Mappers.kt$PartMapper$private fun mapToolStateToDomain(dto: ToolStateDto): ToolState - CyclomaticComplexMethod:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit ) + CyclomaticComplexMethod:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit, providerNames: Map<String, String> = emptyMap() ) CyclomaticComplexMethod:ModelControlsScreen.kt$ModelControlsViewModel$fun loadModels() CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SessionListScreen( viewModel: SessionListViewModel = koinViewModel(), filterProjectId: String? = null, onSessionClick: (sessionId: String, directory: String?) -> Unit, onNewSession: (sessionId: String, directory: String?) -> Unit, onSettings: () -> Unit, onProjects: () -> Unit = {}, onProjectClick: (directory: String) -> Unit = {}, onViewChanges: (sessionId: String) -> Unit = {}, onCreateSessionInWorkspace: (title: String?, directory: String?) -> Unit = { title, directory -> viewModel.createSession(title, directory) }, autoCreateSession: Boolean = false, autoCreateSessionTitle: String? = null, autoCreateSessionDirectory: String? = null, onAutoCreateSessionConsumed: () -> Unit = {}, onNavigateBack: (() -> Unit)? = null ) @@ -56,6 +56,7 @@ FunctionNaming:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, isQueued: Boolean = false, ) FunctionNaming:ChatMessage.kt$@Composable fun AssistantMessages( messagesWithParts: List<MessageWithParts>, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), modifier: Modifier = Modifier ) FunctionNaming:ChatMessage.kt$@Composable fun ChatMessage( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: (() -> Unit)? = null, isQueued: Boolean = false, modifier: Modifier = Modifier, ) + FunctionNaming:ChatMessage.kt$@Composable private fun AssistantAttributionHeader(agent: String, modelID: String) FunctionNaming:ChatMessage.kt$@Composable private fun AssistantError(error: MessageError) FunctionNaming:ChatMessage.kt$@Composable private fun AssistantMessageContent( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), ) FunctionNaming:ChatMessage.kt$@Composable private fun CompactPatchPart(part: Part.Patch) @@ -132,9 +133,9 @@ FunctionNaming:ModelAgentSelector.kt$@Composable private fun ReasoningEffortSelect( efforts: List<String>, selectedEffort: String?, onEffortSelected: (String?) -> Unit, ) FunctionNaming:ModelAgentSelector.kt$@Composable private fun TuiFilterTab( text: String, selected: Boolean, onClick: () -> Unit ) FunctionNaming:ModelAgentSelector.kt$@Composable private fun TuiModelListItem( model: EnhancedModelInfo, isSelected: Boolean, onSelect: () -> Unit, onToggleFavorite: () -> Unit ) - FunctionNaming:ModelAgentSelector.kt$@Composable private fun TuiSectionHeader( text: String, color: Color ) - FunctionNaming:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit ) - FunctionNaming:ModelControlsScreen.kt$@Composable private fun ProviderFilterChips( providers: List<String>, selected: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier ) + FunctionNaming:ModelAgentSelector.kt$@Composable private fun TuiSectionHeader(text: String) + FunctionNaming:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit, providerNames: Map<String, String> = emptyMap() ) + FunctionNaming:ModelControlsScreen.kt$@Composable private fun ProviderFilterChips( providers: List<Pair<String, String>>, selected: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier ) FunctionNaming:ModelControlsScreen.kt$@Composable private fun SearchBar( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier ) FunctionNaming:ModelControlsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelControlsScreen( viewModel: ModelControlsViewModel = koinViewModel(), onNavigateBack: () -> Unit ) FunctionNaming:ModelControlsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModelCard( model: ModelInfo, isSelected: Boolean, onSelect: () -> Unit, onToggleFavorite: () -> Unit ) @@ -231,6 +232,7 @@ FunctionNaming:ToolGroupWidget.kt$@Composable fun ToolGroupWidget( tools: List<Part.Tool>, defaultState: ToolWidgetState, pendingPermissionIdsByCallId: Map<String, String> = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) FunctionNaming:ToolGroupWidget.kt$@Composable private fun PendingApprovalButtonsInline( onApprove: () -> Unit, onDeny: () -> Unit ) FunctionNaming:TuiComponents.kt$@Composable fun TuiAlertDialog( onDismissRequest: () -> Unit, title: String, modifier: Modifier = Modifier, icon: ImageVector? = null, confirmButton: @Composable () -> Unit, dismissButton: @Composable (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) + FunctionNaming:TuiComponents.kt$@Composable fun TuiBackButton( onClick: () -> Unit, modifier: Modifier = Modifier, description: String = stringResource(R.string.cd_back), ) FunctionNaming:TuiComponents.kt$@Composable fun TuiBadge( text: String, modifier: Modifier = Modifier, containerColor: Color = LocalOpenCodeTheme.current.backgroundPanel, contentColor: Color = LocalOpenCodeTheme.current.secondary ) FunctionNaming:TuiComponents.kt$@Composable fun TuiButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, colors: ButtonColors = ButtonDefaults.buttonColors(), contentPadding: PaddingValues = PaddingValues(horizontal = Spacing.lg, vertical = Spacing.xs), content: @Composable RowScope.() -> Unit ) FunctionNaming:TuiComponents.kt$@Composable fun TuiCard( modifier: Modifier = Modifier, colors: CardColors = CardDefaults.cardColors(), onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) @@ -315,7 +317,7 @@ LongMethod:Mappers.kt$PartMapper$fun mapToDomain(dto: PartDto): Part LongMethod:Material3Mapper.kt$fun OpenCodeTheme.toMaterial3ColorScheme(): ColorScheme LongMethod:ModelAgentSelector.kt$@Composable private fun TuiModelListItem( model: EnhancedModelInfo, isSelected: Boolean, onSelect: () -> Unit, onToggleFavorite: () -> Unit ) - LongMethod:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit ) + LongMethod:ModelAgentSelector.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit, providerNames: Map<String, String> = emptyMap() ) LongMethod:ModelControlsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelControlsScreen( viewModel: ModelControlsViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:ModelControlsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModelCard( model: ModelInfo, isSelected: Boolean, onSelect: () -> Unit, onToggleFavorite: () -> Unit ) LongMethod:NavGraph.kt$@Composable fun NavGraph( navController: NavHostController, startDestination: String ) @@ -382,7 +384,7 @@ LongParameterList:HomeScreen.kt$( workspace: WorkspaceSummary, openWork: List<OpenWorkSummary>, onBack: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, ) LongParameterList:LicensesScreen.kt$( entry: LicenseEntry, expanded: Boolean, fullText: String?, isLoading: Boolean, onToggle: () -> Unit, onOpenUpstream: () -> Unit, ) LongParameterList:MessageBlockUtils.kt$( block: MessageBlock, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: ((String) -> Unit)? = null ) - LongParameterList:ModelAgentSelector.kt$( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit ) + LongParameterList:ModelAgentSelector.kt$( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit, providerNames: Map<String, String> = emptyMap() ) LongParameterList:NotificationSettingsScreen.kt$( title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean, testTag: String ) LongParameterList:OpenCodeApi.kt$OpenCodeApi$( @Query("directory") directory: String?, @Query("scope") scope: String? = null, @Query("roots") roots: Boolean? = null, @Query("start") start: Long? = null, @Query("search") search: String? = null, @Query("limit") limit: Int? = null ) LongParameterList:PtyTerminalClient.kt$PtyTerminalClient$( private val context: Context, private val onTextChanged: () -> Unit = {}, private val onTitleChanged: (String?) -> Unit = {}, private val onSessionFinished: () -> Unit = {}, private val onBellCallback: () -> Unit = {}, private val onColorsChangedCallback: () -> Unit = {}, private val onCursorStateChange: (Boolean) -> Unit = {}, private val onPasteRequest: ((String) -> Unit)? = null ) @@ -509,7 +511,7 @@ MagicNumber:HomeScreen.kt$6 MagicNumber:InlineDiffViewer.kt$4 MagicNumber:MdnsDiscoveryManager.kt$MdnsDiscoveryManager$200 - MagicNumber:ModelAgentSelector.kt$0.85f + MagicNumber:ModelAgentSelector.kt$0.7f MagicNumber:ModelControlsScreen.kt$1_000 MagicNumber:ModelControlsScreen.kt$1_000_000 MagicNumber:NotificationHelper.kt$NotificationHelper.Companion$0x0FFFFFFF diff --git a/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt b/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt index 960c99c8..5bf9b1eb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt +++ b/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -20,6 +21,7 @@ import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.PocketCodeTheme import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { @@ -27,8 +29,12 @@ class MainActivity : ComponentActivity() { private val settingsDataStore: SettingsDataStore by inject() private val pendingNotificationRoute = MutableStateFlow(null) + @Volatile + private var startDestinationResolved = false + override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { !startDestinationResolved } super.onCreate(savedInstanceState) pendingNotificationRoute.value = NotificationRouteCodec.read(intent) @@ -49,19 +55,33 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = LocalOpenCodeTheme.current.background ) { - // Use NavGraph for initial Server/Setup screens, - // then MainTabScreen takes over after connection - val navController = rememberNavController() - NavGraph( - navController = navController, - startDestination = Screen.Server.route, - pendingNotificationRoute = pendingNotificationRoute, - onNotificationRouteConsumed = { route -> - if (pendingNotificationRoute.compareAndSet(route, null)) { - NotificationRouteCodec.clear(intent) - } - }, - ) + // First launch (onboarding not completed) shows the first-run Setup + // screen; returning users land on the Server connect screen. Resolved + // from persisted state before the NavGraph composes, so the start + // destination is stable for the navigation back stack. + val startDestination by produceState(initialValue = null) { + value = if (settingsDataStore.onboardingCompleted.first()) { + Screen.Server.route + } else { + Screen.Setup.route + } + startDestinationResolved = true + } + startDestination?.let { destination -> + // Use NavGraph for initial Server/Setup screens, + // then MainTabScreen takes over after connection + val navController = rememberNavController() + NavGraph( + navController = navController, + startDestination = destination, + pendingNotificationRoute = pendingNotificationRoute, + onNotificationRouteConsumed = { route -> + if (pendingNotificationRoute.compareAndSet(route, null)) { + NotificationRouteCodec.clear(intent) + } + }, + ) + } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index 6e91b941..84709940 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -103,7 +103,7 @@ class SettingsDataStore constructor( private val KEY_THEME_NAME = stringPreferencesKey("theme_name") private val KEY_OLED_BLACK = booleanPreferencesKey("oled_black") - const val DEFAULT_THEME_NAME = "catppuccin" + const val DEFAULT_THEME_NAME = "opencode" private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") private val KEY_RECENT_SERVERS = stringPreferencesKey("recent_servers") private val KEY_SAVED_SERVERS = stringPreferencesKey("saved_servers_v1") @@ -137,6 +137,7 @@ class SettingsDataStore constructor( private val KEY_NOTIFY_VIBRATE_ON_COMPLETION = booleanPreferencesKey("notify_vibrate_on_completion") private val KEY_NOTIFY_VIBRATION_PATTERN = stringPreferencesKey("notify_vibration_pattern") private val KEY_NOTIFY_ON_COMPLETION = booleanPreferencesKey("notify_on_completion") + private val KEY_NOTIFY_SERVER_ROUTING = stringPreferencesKey("notify_server_routing") // Chat settings keys private val KEY_CHAT_ENTER_TO_SEND = booleanPreferencesKey("chat_enter_to_send") @@ -551,9 +552,18 @@ class SettingsDataStore constructor( vibrationPattern = prefs[KEY_NOTIFY_VIBRATION_PATTERN]?.toVibrationPattern() ?: if (prefs[KEY_NOTIFY_VIBRATE_ON_COMPLETION] == true) VibrationPattern.Tick else VibrationPattern.None, notifyOnCompletion = prefs[KEY_NOTIFY_ON_COMPLETION] ?: false, + serverRouting = decodeServerRouting(prefs[KEY_NOTIFY_SERVER_ROUTING]), ) } + private fun decodeServerRouting(stored: String?): Map { + if (stored.isNullOrBlank()) return emptyMap() + return runCatching { + json.decodeFromString>(stored) + .mapValues { NotificationRoutingMode.fromStorage(it.value) } + }.getOrDefault(emptyMap()) + } + suspend fun updateNotificationSettings(settings: NotificationSettings) { context.dataStore.edit { prefs -> prefs[KEY_NOTIFICATIONS_ENABLED] = settings.enabled @@ -562,6 +572,13 @@ class SettingsDataStore constructor( prefs[KEY_NOTIFY_VIBRATION_PATTERN] = settings.vibrationPattern.storageValue prefs[KEY_NOTIFY_ON_COMPLETION] = settings.notifyOnCompletion prefs.remove(KEY_NOTIFY_VIBRATE_ON_COMPLETION) + val nonDefault = settings.serverRouting.filterValues { it != NotificationRoutingMode.All } + if (nonDefault.isEmpty()) { + prefs.remove(KEY_NOTIFY_SERVER_ROUTING) + } else { + prefs[KEY_NOTIFY_SERVER_ROUTING] = + json.encodeToString(nonDefault.mapValues { it.value.storageValue }) + } } } @@ -995,8 +1012,27 @@ data class NotificationSettings( val questions: Boolean = true, val vibrationPattern: VibrationPattern = VibrationPattern.None, val notifyOnCompletion: Boolean = false, + /** Per-server notification routing, keyed by endpointKey. Absent = All (design 18). */ + val serverRouting: Map = emptyMap(), ) +/** + * Per-server notification routing (design 18). + * - [All]: deliver every enabled notification type. + * - [Mentions]: only agent-awaiting-input (permission / question); suppress turn-complete. + * - [Off]: suppress all notifications for the server. + */ +enum class NotificationRoutingMode(val storageValue: String) { + All("all"), + Mentions("mentions"), + Off("off"); + + companion object { + fun fromStorage(value: String?): NotificationRoutingMode = + entries.firstOrNull { it.storageValue == value } ?: All + } +} + enum class VibrationPattern(val storageValue: String) { None("none"), Tick("tick"), diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt index 612d8d3f..8d2a43b4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt @@ -3,8 +3,10 @@ package dev.blazelight.p4oc.core.notification import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import dev.blazelight.p4oc.core.datastore.NotificationRoutingMode import dev.blazelight.p4oc.core.datastore.NotificationSettings import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ServerConnectionRegistry @@ -97,8 +99,14 @@ class NotificationEventObserver constructor( } } + /** Per-server routing gate; absent entry defaults to [NotificationRoutingMode.All]. */ + private fun routingModeFor(serverRef: ServerRef): NotificationRoutingMode = + cachedSettings.serverRouting[serverRef.endpointKey] ?: NotificationRoutingMode.All + private fun handlePermission(scopedEvent: ScopedEvent, event: OpenCodeEvent.PermissionRequested) { if (isInForeground || !cachedSettings.enabled || !cachedSettings.permissionRequests) return + // Awaiting-input events are "mentions": All and Mentions deliver, Off suppresses. + if (!shouldDeliverAwaitingInput(routingModeFor(scopedEvent.serverRef))) return AppLog.d(TAG, "Permission requested in background") notificationHelper.showPermissionNotification( sessionId = event.permission.sessionID, @@ -110,6 +118,7 @@ class NotificationEventObserver constructor( private fun handleQuestion(scopedEvent: ScopedEvent, event: OpenCodeEvent.QuestionAsked) { if (isInForeground || !cachedSettings.enabled || !cachedSettings.questions) return + if (!shouldDeliverAwaitingInput(routingModeFor(scopedEvent.serverRef))) return AppLog.d(TAG, "Question asked in background") notificationHelper.showQuestionNotification( sessionId = event.request.sessionID, @@ -137,6 +146,8 @@ class NotificationEventObserver constructor( } private fun showCompletionFeedback(route: NotificationRoute) { + // Turn-complete is not a "mention": only the All routing mode delivers it. + if (!shouldDeliverCompletion(routingModeFor(route.serverRef))) return if (cachedSettings.notifyOnCompletion) { hapticFeedback.vibrate(cachedSettings.vibrationPattern) notificationHelper.showCompletionNotification( @@ -158,6 +169,14 @@ class NotificationEventObserver constructor( internal fun shouldEmitCompletionFeedback(settings: NotificationSettings, isInForeground: Boolean): Boolean = settings.enabled && settings.notifyOnCompletion && !isInForeground +/** Awaiting-input (permission/question) notifications: delivered unless the server is routed Off. */ +internal fun shouldDeliverAwaitingInput(mode: NotificationRoutingMode): Boolean = + mode != NotificationRoutingMode.Off + +/** Turn-complete notifications: only the All routing mode delivers them (Mentions/Off suppress). */ +internal fun shouldDeliverCompletion(mode: NotificationRoutingMode): Boolean = + mode == NotificationRoutingMode.All + internal class CompletionTracker { private val busySessions = mutableSetOf() diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt index 3d0e0323..1d5cc230 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt @@ -18,11 +18,16 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -518,6 +523,108 @@ fun TuiSection( } } +/** + * Design back affordance: a plain "←" glyph rather than an Icon/IconButton, matching + * the terminal aesthetic. This is the single canonical back button — every screen-level + * back control (TuiTopBar, dialogs, sub-views) routes through this. + */ +@Composable +fun TuiBackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String = stringResource(R.string.cd_back), +) { + val theme = LocalOpenCodeTheme.current + Text( + "←", + style = MaterialTheme.typography.titleMedium, + color = theme.text, + modifier = modifier + .clickable(role = Role.Button, onClick = onClick) + .semantics { contentDescription = description } + .padding(Spacing.xs), + ) +} + +/** + * Design section header: monospace, letter-spaced, uppercase, on a full-width + * panel-background bar (matches SERVERS·PROJECTS / RECENT / EVENT TYPES labels). + */ +@Composable +fun TuiSectionHeader( + text: String, + modifier: Modifier = Modifier, + trailing: @Composable (RowScope.() -> Unit)? = null, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = modifier + .fillMaxWidth() + .background(theme.backgroundPanel) + .padding(horizontal = Spacing.md, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 1.sp, + ), + color = theme.textMuted, + modifier = Modifier.weight(1f), + ) + trailing?.invoke(this) + } +} + +/** + * Design segmented control: a bordered row of connected cells. The active cell + * is filled (#1e1e1e element) with semibold primary text; others are muted. + * Matches the mode / tool-display / effort selectors. + */ +@Composable +fun TuiSegmentedControl( + options: List>, + selectedId: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = modifier + .fillMaxWidth() + .height(Sizing.buttonHeightMd) + .border(Sizing.strokeMd, theme.border, RectangleShape), + ) { + options.forEachIndexed { index, (id, label) -> + if (index > 0) { + Box( + modifier = Modifier + .width(Sizing.strokeThin) + .fillMaxHeight() + .background(theme.border) + ) + } + val active = id == selectedId + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(if (active) theme.backgroundElement else Color.Transparent) + .clickable(role = Role.RadioButton) { onSelect(id) }, + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + color = if (active) theme.primary else theme.textMuted, + ) + } + } + } +} + /** * TUI-style bottom sheet content wrapper. */ @@ -802,7 +909,7 @@ fun TuiStatusDot( Surface( modifier = modifier.size(size), color = color, - shape = androidx.compose.foundation.shape.CircleShape + shape = RectangleShape ) {} } @@ -882,88 +989,38 @@ fun TuiSwitch( val theme = LocalOpenCodeTheme.current val contentAlpha = if (enabled) 1f else 0.38f - // Active cell: solid accent background with inverted text - val activeBg = theme.accent.copy(alpha = contentAlpha) - val activeText = theme.background.copy(alpha = contentAlpha) - - // Inactive cell: panel background with muted text - val inactiveBg = theme.backgroundPanel.copy(alpha = contentAlpha) - val inactiveText = theme.textMuted.copy(alpha = contentAlpha) - - val outerBorder = theme.border.copy(alpha = contentAlpha) - val dividerColor = theme.borderSubtle.copy(alpha = contentAlpha) - - val monoLabel = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - letterSpacing = 0.5.sp - ) + // Knob-slider (design): 36x20 track, square knob slides right (on, success) / left (off, muted). + val knobColor = (if (checked) theme.success else theme.textMuted).copy(alpha = contentAlpha) + val trackBg = theme.backgroundPanel.copy(alpha = contentAlpha) + val trackBorder = theme.border.copy(alpha = contentAlpha) - Row( + Box( modifier = modifier - .height(Sizing.buttonHeightSm) - .border( - width = Sizing.strokeMd, - color = outerBorder, - shape = RectangleShape - ) + .width(Sizing.switchTrackWidth) + .height(Sizing.switchTrackHeight) + .background(trackBg, RectangleShape) + .border(Sizing.strokeMd, trackBorder, RectangleShape) .then( if (onCheckedChange != null && enabled) { Modifier.toggleable( value = checked, interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), + indication = null, role = Role.Switch, onValueChange = onCheckedChange ) } else { Modifier } - ), - verticalAlignment = Alignment.CenterVertically - ) { - // OFF cell (left) - Box( - modifier = Modifier - .width(Sizing.switchCellWidth) - .fillMaxHeight() - .background( - color = if (!checked) activeBg else inactiveBg, - shape = RectangleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = "OFF", - style = monoLabel, - color = if (!checked) activeText else inactiveText ) - } - - // Vertical divider + .padding(Sizing.switchKnobInset), + contentAlignment = if (checked) Alignment.CenterEnd else Alignment.CenterStart + ) { Box( modifier = Modifier - .width(Sizing.strokeMd) - .fillMaxHeight() - .background(dividerColor) + .size(Sizing.switchKnobSize) + .background(knobColor, RectangleShape) ) - - // ON cell (right) - Box( - modifier = Modifier - .width(Sizing.switchCellWidth) - .fillMaxHeight() - .background( - color = if (checked) activeBg else inactiveBg, - shape = RectangleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = "ON", - style = monoLabel, - color = if (checked) activeText else inactiveText - ) - } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiTopBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiTopBar.kt index da7bb88d..f4819546 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiTopBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiTopBar.kt @@ -1,16 +1,13 @@ package dev.blazelight.p4oc.ui.components import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.blazelight.p4oc.R import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -37,7 +34,7 @@ fun TuiTopBar( val theme = LocalOpenCodeTheme.current Surface( modifier = modifier.fillMaxWidth(), - color = theme.backgroundElement, + color = theme.background, tonalElevation = 0.dp ) { Column { @@ -50,16 +47,9 @@ fun TuiTopBar( verticalAlignment = Alignment.CenterVertically ) { if (onNavigateBack != null) { - IconButton( - onClick = onNavigateBack, - modifier = Modifier.size(Sizing.iconButtonMd) - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), - modifier = Modifier.size(Sizing.iconLg) - ) - } + TuiBackButton(onClick = onNavigateBack) + // Breathing room so the back button isn't crowded by the title. + Spacer(Modifier.width(Spacing.sm)) } if (titleContent != null) { @@ -72,12 +62,15 @@ fun TuiTopBar( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ) ) } actions() } + HorizontalDivider(color = theme.border, thickness = Sizing.strokeThin) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 912cda3d..64aaea50 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -17,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -26,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle @@ -33,6 +36,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Command @@ -165,7 +169,7 @@ fun ChatInputBar( Box(modifier = modifier.fillMaxWidth()) { Surface( - color = theme.backgroundElement, + color = theme.background, shape = RectangleShape ) { Column { @@ -238,51 +242,65 @@ fun ChatInputBar( Row( modifier = Modifier - .padding(horizontal = Spacing.md, vertical = Spacing.xs) + .padding(horizontal = Spacing.md, vertical = Spacing.sm) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.xs) + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { + // One shared FIXED height for the attach button, the field and the + // send button so all three are exactly equal regardless of the + // system font scale. (A text-driven field height diverges from the + // fixed-dp buttons on devices with a large font setting.) + val controlSize = Sizing.iconButtonLg + if (enabled) { - IconButton( + ComposerSquareButton( + glyph = "+", + glyphColor = theme.textMuted, + fillColor = Color.Transparent, + borderColor = theme.border, + size = controlSize, onClick = onAttachClick, - modifier = Modifier - .size(Sizing.iconButtonMd) - .semantics { contentDescription = attachDescription } - .testTag("chat_attach_button") - ) { - Text( - text = "+", - color = theme.accent, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleMedium - ) - } + contentDescription = attachDescription, + testTag = "chat_attach_button", + ) } Box( modifier = Modifier .weight(1f) - .heightIn(min = Sizing.textFieldHeightSm) + .height(controlSize) .border( width = Sizing.strokeMd, color = theme.border, shape = RectangleShape ) .background( - theme.background, + theme.backgroundPanel, RectangleShape ) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), + .padding(horizontal = Spacing.md), contentAlignment = Alignment.CenterStart ) { if (currentText.isEmpty()) { - Text( - "> Message...", - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.textMuted - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "▷ ", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = TuiCodeFontSize.xxl + ), + fontFamily = FontFamily.Monospace, + color = theme.primary + ) + Text( + "Message OpenCode…", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = TuiCodeFontSize.xxl + ), + fontFamily = FontFamily.Monospace, + color = theme.textMuted + ) + } } BasicTextField( state = textState, @@ -348,46 +366,36 @@ fun ChatInputBar( } if (isBusy) { - IconButton( + ComposerSquareButton( + glyph = "■", + glyphColor = theme.error, + fillColor = Color.Transparent, + borderColor = theme.border, + size = controlSize, onClick = onAbort, - modifier = Modifier - .size(Sizing.iconButtonMd) - .semantics { contentDescription = stopDescription } - .testTag("chat_abort_button") - ) { - Text( - text = "■", - color = theme.error, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleMedium - ) - } + contentDescription = stopDescription, + testTag = "chat_abort_button", + ) } - IconButton( + // Filled by default (design 05) — emptiness is enforced by the click + // guard, not by dimming the button. + ComposerSquareButton( + glyph = "↑", + glyphColor = theme.background, + fillColor = theme.primary, + borderColor = theme.primary, + size = controlSize, onClick = { - if (!canSubmit) return@IconButton + if (!canSubmit) return@ComposerSquareButton onSend() clearInput() focusRequester.requestFocus() }, - enabled = canSubmit, - modifier = Modifier - .size(Sizing.iconButtonMd) - .semantics { contentDescription = sendContentDescription } - .testTag("send_button") - ) { - if (isLoading) { - TuiLoadingIndicator() - } else { - Text( - text = "↑", - color = if (canSubmit) theme.accent else theme.textMuted, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleMedium - ) - } - } + contentDescription = sendContentDescription, + testTag = "send_button", + loading = isLoading, + ) } } } @@ -418,3 +426,49 @@ fun ChatInputBar( } } } + +/** + * Flat, perfectly-square composer control (attach / send / abort). + * + * Deliberately NOT a Material3 [IconButton]: that component forces a 48dp + * `minimumInteractiveComponentSize` and its own internal state layer, which + * overrides an explicit `.size(...)` and leaves the border floating inside a + * taller slot. A plain clickable [Box] lays out at exactly [size] so the + * attach button, the text field and the send button share one height and the + * borders are true squares. + */ +@Composable +@Suppress("LongParameterList", "FunctionNaming") +private fun ComposerSquareButton( + glyph: String, + glyphColor: Color, + fillColor: Color, + borderColor: Color, + size: Dp, + onClick: () -> Unit, + contentDescription: String, + testTag: String, + loading: Boolean = false, +) { + Box( + modifier = Modifier + .size(size) + .background(fillColor, RectangleShape) + .border(Sizing.strokeMd, borderColor, RectangleShape) + .clickable(role = Role.Button, onClick = onClick) + .semantics { this.contentDescription = contentDescription } + .testTag(testTag), + contentAlignment = Alignment.Center + ) { + if (loading) { + TuiLoadingIndicator() + } else { + Text( + text = glyph, + color = glyphColor, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleMedium + ) + } + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt index ac4f5178..c4c5028c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.components.chat +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.* @@ -29,6 +31,7 @@ import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator import dev.blazelight.p4oc.ui.components.toolwidgets.ToolGroupWidget import dev.blazelight.p4oc.ui.components.toolwidgets.ToolWidgetState import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.SemanticColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -125,25 +128,16 @@ private fun UserMessage( // Don't render anything if there's no visible text if (text.isBlank()) return - // TUI style: Distinct background with accent left border for user messages - Row( + // TUI style: flat panel surface with a "you" label — matches the design's user block. + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = Spacing.xs) ) { - // Accent left border (thicker for user messages) - Box( - modifier = Modifier - .width(Spacing.xs) - .fillMaxHeight() - .background(theme.primary) - ) - - // Content with distinct background - use primary tint for better contrast Box( modifier = Modifier .fillMaxWidth() - .background(theme.primary.copy(alpha = 0.12f)) + .background(theme.backgroundPanel) .combinedClickable( onClick = {}, onLongClick = { @@ -165,6 +159,13 @@ private fun UserMessage( .fillMaxWidth() .padding(end = revertEndInset) ) { + Text( + text = "you", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + modifier = Modifier.padding(bottom = Spacing.xxs) + ) StreamingMarkdown(text = text, modifier = Modifier.fillMaxWidth()) if (isQueued) { @@ -216,6 +217,13 @@ private fun AssistantMessageContent( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.hairline) ) { + // Per-turn attribution header: `@build · claude-sonnet-4-5` (design 05). + (messageWithParts.message as? Message.Assistant)?.let { assistant -> + if (partGroups.isNotEmpty()) { + AssistantAttributionHeader(agent = assistant.agent, modelID = assistant.modelID) + } + } + // Render part groups in order partGroups.forEach { group -> when (group) { @@ -282,6 +290,42 @@ private fun renderOtherPart(part: Part) { } } +/** + * Assistant turn attribution header — `@build · claude-sonnet-4-5` (design 05). + * `@agent` takes the agent's accent color; the model id trails muted. + */ +@Composable +private fun AssistantAttributionHeader(agent: String, modelID: String) { + val theme = LocalOpenCodeTheme.current + val agentColor = SemanticColors.AgentSelector.forName(agent) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = Spacing.xs, bottom = Spacing.xxs), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "@${agent.lowercase()}", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + fontWeight = FontWeight.SemiBold, + color = agentColor + ) + Text( + text = "·", + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted + ) + Text( + text = modelID, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } +} + @Composable private fun activityMarker(label: String) { val theme = LocalOpenCodeTheme.current @@ -297,33 +341,66 @@ private fun activityMarker(label: String) { @Suppress("FunctionNaming") private fun AssistantError(error: MessageError, onProviderAuthRequired: ((String) -> Unit)? = null) { val theme = LocalOpenCodeTheme.current + val isAuth = error.name == "ProviderAuthError" + val isAborted = error.name == "MessageAbortedError" val message = when { - error.name == "MessageAbortedError" -> stringResource(R.string.chat_run_aborted) - error.name == "ProviderAuthError" -> stringResource(R.string.chat_provider_auth_required) + isAborted -> stringResource(R.string.chat_run_aborted) + isAuth -> stringResource(R.string.chat_provider_auth_required) error.isRetryable -> stringResource(R.string.chat_run_retryable_error) else -> stringResource(R.string.chat_run_failed) } + val accent = if (isAborted) theme.warning else theme.error + val header = buildString { + append( + when { + isAuth -> "provider auth error" + isAborted -> "run aborted" + error.isRetryable -> "run error · retryable" + else -> "run failed" + } + ) + error.statusCode?.let { append(" · "); append(it) } + } - Box( + // Left-border error card, matching design 20's provider-auth banner. + Row( modifier = Modifier .fillMaxWidth() - .background(theme.error.copy(alpha = 0.1f)) - .padding(horizontal = Spacing.sm, vertical = Spacing.xs) + .height(IntrinsicSize.Min) + .background(theme.backgroundElement) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + Box( + modifier = Modifier + .width(Sizing.strokeThick) + .fillMaxHeight() + .background(accent) + ) + Column( + modifier = Modifier.padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { + Text( + text = header, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = accent + ) Text( text = message, - modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, - color = theme.error + color = theme.text ) - if (error.name == "ProviderAuthError" && error.providerID != null && onProviderAuthRequired != null) { - TextButton(onClick = { onProviderAuthRequired(error.providerID) }) { - Text(stringResource(R.string.provider_auth_action)) + if (isAuth && error.providerID != null && onProviderAuthRequired != null) { + OutlinedButton( + onClick = { onProviderAuthRequired(error.providerID) }, + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = Spacing.md, vertical = Spacing.none), + border = BorderStroke(Sizing.strokeMd, theme.primary), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.primary) + ) { + Text( + stringResource(R.string.provider_auth_action), + style = MaterialTheme.typography.labelSmall + ) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt index 8f7c0aaf..4b3ad806 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.components.chat +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material3.* @@ -32,21 +33,29 @@ fun InlinePermissionPrompt( ) { val theme = LocalOpenCodeTheme.current - Column( + Row( modifier = modifier .fillMaxWidth() - .background(theme.warning.copy(alpha = 0.1f)) - .padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + .height(IntrinsicSize.Min) + .background(theme.backgroundElement) ) { - // Permission title with icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + // Left accent strip — matches the design's `border-left:2px` permission card. + Box( + modifier = Modifier + .width(Sizing.strokeThick) + .fillMaxHeight() + .background(theme.warning) + ) + Column( + modifier = Modifier.padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { + // Header: "permission · " Text( - text = "◉", - style = MaterialTheme.typography.labelMedium, + text = "permission · ${permission.type}", + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = FontFamily.Monospace + ), color = theme.warning ) Text( @@ -55,69 +64,67 @@ fun InlinePermissionPrompt( fontFamily = FontFamily.Monospace, fontSize = TuiCodeFontSize.lg ), - color = theme.text, - modifier = Modifier.weight(1f) + color = theme.text ) - } - // Action buttons row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - OutlinedButton( - onClick = onReject, - modifier = Modifier - .weight(1f) - .minimumInteractiveComponentSize() - .heightIn(min = Sizing.minTouchTarget) - .testTag("permission_deny_${permission.id}"), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = theme.error - ) + // Action buttons row — allow once / always / deny + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - Text( - stringResource(R.string.deny), - style = MaterialTheme.typography.labelSmall - ) - } + OutlinedButton( + onClick = onAllow, + modifier = Modifier + .weight(1f) + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_allow_once_${permission.id}"), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), + border = BorderStroke(Sizing.strokeMd, theme.success), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.success) + ) { + Text( + stringResource(R.string.allow), + style = MaterialTheme.typography.labelSmall + ) + } - OutlinedButton( - onClick = onAlways, - modifier = Modifier - .weight(1f) - .minimumInteractiveComponentSize() - .heightIn(min = Sizing.minTouchTarget) - .testTag("permission_always_allow_${permission.id}"), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none) - ) { - Text( - stringResource(R.string.always_allow), - style = MaterialTheme.typography.labelSmall - ) - } + OutlinedButton( + onClick = onAlways, + modifier = Modifier + .weight(1f) + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_always_allow_${permission.id}"), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), + border = BorderStroke(Sizing.strokeMd, theme.border), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.textMuted) + ) { + Text( + stringResource(R.string.always_allow), + style = MaterialTheme.typography.labelSmall + ) + } - Button( - onClick = onAllow, - modifier = Modifier - .weight(1f) - .minimumInteractiveComponentSize() - .heightIn(min = Sizing.minTouchTarget) - .testTag("permission_allow_once_${permission.id}"), - shape = RectangleShape, - contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), - colors = ButtonDefaults.buttonColors( - containerColor = theme.success, - contentColor = theme.background - ) - ) { - Text( - stringResource(R.string.allow), - style = MaterialTheme.typography.labelSmall - ) + OutlinedButton( + onClick = onReject, + modifier = Modifier + .weight(1f) + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_deny_${permission.id}"), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), + border = BorderStroke(Sizing.strokeMd, theme.error), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.error) + ) { + Text( + stringResource(R.string.deny), + style = MaterialTheme.typography.labelSmall + ) + } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt index bd519894..ea171add 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.components.chat +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -20,8 +21,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import dev.blazelight.p4oc.R import dev.blazelight.p4oc.data.remote.dto.AgentDto import dev.blazelight.p4oc.data.remote.dto.ModelDto @@ -73,6 +78,8 @@ fun ModelAgentSelectorBar( favoriteModels: Set = emptySet(), recentModels: List = emptyList(), onToggleFavorite: (ModelInput) -> Unit = {}, + usedContextTokens: Int? = null, + providerNames: Map = emptyMap(), modifier: Modifier = Modifier ) { val theme = LocalOpenCodeTheme.current @@ -98,12 +105,21 @@ fun ModelAgentSelectorBar( Surface( modifier = modifier.fillMaxWidth().testTag("agent_selector"), - color = theme.backgroundElement + color = theme.background ) { Row( + modifier = Modifier.padding( + start = Spacing.md, + end = Spacing.md, + top = Spacing.md, + bottom = Spacing.xs, + ), + verticalAlignment = Alignment.CenterVertically + ) { + Row( modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = Spacing.md, vertical = Spacing.xs), + .weight(1f) + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(Spacing.md), verticalAlignment = Alignment.CenterVertically ) { @@ -231,6 +247,14 @@ fun ModelAgentSelectorBar( onEffortSelected = onReasoningEffortSelected, ) } + } + val ctxWindow = selectedModelDto?.limit?.context + if (usedContextTokens != null && ctxWindow != null && ctxWindow > 0) { + Spacer(Modifier.width(Spacing.sm)) + ContextUsageMeter( + percent = (usedContextTokens.toLong() * 100 / ctxWindow).toInt().coerceIn(0, 100) + ) + } } } @@ -240,6 +264,7 @@ fun ModelAgentSelectorBar( selectedModel = selectedModel, favoriteModels = favoriteModels, recentModels = recentModels, + providerNames = providerNames, onModelSelected = { onModelSelected(it) showModelPicker = false @@ -250,6 +275,32 @@ fun ModelAgentSelectorBar( } } +/** + * Compact context-window usage meter for the composer control row — `▓▓▓░ 62%`. + * Matches design 05; four cells, warning/error tint as usage climbs. + */ +@Composable +private fun ContextUsageMeter(percent: Int) { + val theme = LocalOpenCodeTheme.current + val filled = (percent * 4 + 99) / 100 // ceil to nearest of 4 cells + val bar = buildString { repeat(4) { append(if (it < filled) '▓' else '░') } } + val color = when { + percent >= 90 -> theme.error + percent >= 60 -> theme.warning + else -> theme.textMuted + } + Text( + text = "$bar $percent%", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = color, + maxLines = 1, + modifier = Modifier.semantics { + contentDescription = "Context usage $percent percent" + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerDialog( @@ -259,19 +310,20 @@ fun ModelPickerDialog( recentModels: List, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + providerNames: Map = emptyMap() ) { val theme = LocalOpenCodeTheme.current var searchQuery by remember { mutableStateOf("") } var selectedCategory by remember { mutableStateOf(null) } - val enhancedModels = remember(availableModels, favoriteModels, recentModels) { + val enhancedModels = remember(availableModels, favoriteModels, recentModels, providerNames) { availableModels.map { (providerId, model) -> val modelInput = ModelInput(providerID = providerId, modelID = model.id) EnhancedModelInfo( model = modelInput, name = model.name, - providerName = providerId.replaceFirstChar { it.uppercase() }, + providerName = providerNames[providerId] ?: providerId, contextWindow = model.limit?.context, hasReasoning = model.capabilities?.reasoning == true, hasTools = model.capabilities?.toolcall == true, @@ -282,14 +334,20 @@ fun ModelPickerDialog( } } + // Provider id paired with the label to show for it — the provider's own display + // name when it has one, so config-defined providers read "LLM Proxy", not "llmproxy". val providers = remember(enhancedModels) { - enhancedModels.map { it.model.providerID }.distinct().sorted() + enhancedModels + .map { it.model.providerID to it.providerName } + .distinct() + .sortedBy { it.second.lowercase() } } val filteredModels = remember(enhancedModels, searchQuery, selectedCategory) { enhancedModels.filter { model -> val matchesSearch = searchQuery.isBlank() || model.name.contains(searchQuery, ignoreCase = true) || + model.providerName.contains(searchQuery, ignoreCase = true) || model.model.providerID.contains(searchQuery, ignoreCase = true) val matchesCategory = selectedCategory == null || model.model.providerID == selectedCategory matchesSearch && matchesCategory @@ -304,69 +362,82 @@ fun ModelPickerDialog( val recents = filteredModels.filter { it.isRecent && !it.isFavorite } val others = filteredModels.filter { !it.isFavorite && !it.isRecent } - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxWidth() - .fillMaxHeight(0.85f), + .fillMaxHeight(0.7f) + .testTag("model_picker_sheet"), shape = RectangleShape, - color = theme.background, + color = theme.backgroundPanel, border = androidx.compose.foundation.BorderStroke(Sizing.strokeMd, theme.border) ) { Column { - // TUI-style header - Surface( - color = theme.backgroundElement, - modifier = Modifier.fillMaxWidth() + // Header: "Select Model" title + close — flush on the sheet's panel bg. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = Spacing.md, end = Spacing.xs, top = Spacing.sm, bottom = Spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Text( + text = stringResource(R.string.select_model), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = theme.text + ) + IconButton( + onClick = onDismiss, + modifier = Modifier.size(Sizing.iconButtonSm) ) { Text( - text = "[ ${stringResource(R.string.select_model)} ]", - style = MaterialTheme.typography.titleSmall, - color = theme.text + text = "×", + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = theme.textMuted ) - IconButton( - onClick = onDismiss, - modifier = Modifier.size(Sizing.iconButtonSm) - ) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = theme.textMuted, - modifier = Modifier.size(Sizing.iconSm) - ) - } } } - // Search field - TUI style + // Search field — inset element-bg box with `/` prompt prefix. + // Height is pinned (not `heightIn`/intrinsic) so the box can't grow as text + // is typed — BasicTextField's measured height otherwise nudges the container. Surface( color = theme.backgroundElement, - modifier = Modifier.fillMaxWidth() + shape = RectangleShape, + border = androidx.compose.foundation.BorderStroke(Sizing.strokeMd, theme.borderSubtle), + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightMd) + .padding(horizontal = Spacing.md, vertical = Spacing.xs) ) { Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.xs), + .fillMaxSize() + .padding(horizontal = Spacing.md), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { Text( text = "/", style = MaterialTheme.typography.bodyMedium, - color = theme.accent + fontFamily = FontFamily.Monospace, + color = theme.primary ) androidx.compose.foundation.text.BasicTextField( value = searchQuery, onValueChange = { searchQuery = it }, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodyMedium.copy(color = theme.text), + modifier = Modifier.weight(1f).testTag("model_search_field"), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = theme.text, + fontFamily = FontFamily.Monospace + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(theme.primary), singleLine = true, decorationBox = { innerTextField -> Box { @@ -374,6 +445,7 @@ fun ModelPickerDialog( Text( text = stringResource(R.string.models_search_placeholder), style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, color = theme.textMuted ) } @@ -397,7 +469,7 @@ fun ModelPickerDialog( } } - // Provider filter tabs - TUI style + // Provider filter chips Row( modifier = Modifier .horizontalScroll(rememberScrollState()) @@ -410,12 +482,12 @@ fun ModelPickerDialog( selected = selectedCategory == null, onClick = { selectedCategory = null } ) - providers.forEach { provider -> + providers.forEach { (providerId, label) -> TuiFilterTab( - text = provider.lowercase(), - selected = selectedCategory == provider, + text = label, + selected = selectedCategory == providerId, onClick = { - selectedCategory = if (selectedCategory == provider) null else provider + selectedCategory = if (selectedCategory == providerId) null else providerId } ) } @@ -432,7 +504,7 @@ fun ModelPickerDialog( ) { if (favorites.isNotEmpty()) { item { - TuiSectionHeader(text = "★ favorites", color = theme.warning) + TuiSectionHeader(text = "★ favorites") } items(favorites, key = { "${it.model.providerID}/${it.model.modelID}" }) { model -> TuiModelListItem( @@ -446,7 +518,7 @@ fun ModelPickerDialog( if (recents.isNotEmpty()) { item { - TuiSectionHeader(text = "◷ recent", color = theme.accent) + TuiSectionHeader(text = "recent") } items(recents, key = { "${it.model.providerID}/${it.model.modelID}" }) { model -> TuiModelListItem( @@ -461,8 +533,7 @@ fun ModelPickerDialog( if (others.isNotEmpty()) { item { TuiSectionHeader( - text = if (favorites.isEmpty() && recents.isEmpty()) "models" else "other", - color = theme.textMuted + text = if (favorites.isEmpty() && recents.isEmpty()) "models" else "other" ) } items(others, key = { "${it.model.providerID}/${it.model.modelID}" }) { model -> @@ -486,6 +557,7 @@ fun ModelPickerDialog( Text( text = "-- no models found --", style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, color = theme.textMuted ) } @@ -493,20 +565,19 @@ fun ModelPickerDialog( } } + HorizontalDivider(color = theme.border, thickness = Sizing.dividerThickness) + // Footer with count - Surface( - color = theme.backgroundElement, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "${filteredModels.size} models", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs) - ) - } + Text( + text = "${filteredModels.size} models", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) } } + } } } @@ -579,13 +650,8 @@ private fun TuiFilterTab( ) { val theme = LocalOpenCodeTheme.current Surface( - color = if (selected) theme.accent.copy(alpha = 0.15f) else Color.Transparent, + color = if (selected) theme.backgroundElement else Color.Transparent, shape = RectangleShape, - border = if (selected) { - androidx.compose.foundation.BorderStroke(Sizing.strokeMd, theme.accent.copy(alpha = 0.5f)) - } else { - null - }, modifier = Modifier.selectable( selected = selected, onClick = onClick, @@ -595,22 +661,30 @@ private fun TuiFilterTab( Text( text = text, style = MaterialTheme.typography.labelSmall, - color = if (selected) theme.accent else theme.textMuted, - modifier = Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xxs) + fontFamily = FontFamily.Monospace, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) theme.primary else theme.textMuted, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs) ) } } +/** Uppercase, letter-spaced section label — `RECENT` / `OTHER`, matching design 06. */ @Composable -private fun TuiSectionHeader( - text: String, - color: Color -) { +private fun TuiSectionHeader(text: String) { + val theme = LocalOpenCodeTheme.current Text( - text = text, + text = text.uppercase(), style = MaterialTheme.typography.labelSmall, - color = color, - modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs) + fontFamily = FontFamily.Monospace, + letterSpacing = 1.sp, + color = theme.textMuted, + modifier = Modifier.padding( + start = Spacing.md, + end = Spacing.md, + top = Spacing.md, + bottom = Spacing.xs + ) ) } @@ -624,17 +698,26 @@ private fun TuiModelListItem( val theme = LocalOpenCodeTheme.current val addFavoriteDescription = stringResource(R.string.cd_add_to_favorites) val removeFavoriteDescription = stringResource(R.string.cd_remove_from_favorites) + val providerColor = SemanticColors.Provider.forName(model.model.providerID).first - Surface( - color = if (isSelected) theme.accent.copy(alpha = 0.1f) else Color.Transparent, + Row( modifier = Modifier .fillMaxWidth() + .height(IntrinsicSize.Min) + .background(if (isSelected) theme.backgroundElement else Color.Transparent) .selectable( selected = isSelected, onClick = onSelect, role = Role.RadioButton, ) ) { + // Left accent strip on the selected row. + Box( + modifier = Modifier + .width(Sizing.strokeThick) + .fillMaxHeight() + .background(if (isSelected) theme.success else Color.Transparent) + ) Row( modifier = Modifier .fillMaxWidth() @@ -642,76 +725,74 @@ private fun TuiModelListItem( horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalAlignment = Alignment.CenterVertically ) { - // Selection indicator + // Selection chevron (reserves width so squares stay aligned). Text( text = if (isSelected) ">" else " ", style = MaterialTheme.typography.bodyMedium, - color = theme.accent + fontFamily = FontFamily.Monospace, + color = theme.primary + ) + + // Provider color square. + Text( + text = "■", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = providerColor ) // Model info Column(modifier = Modifier.weight(1f)) { + Text( + text = model.name, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Metadata row: provider · context Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = model.name, - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) theme.text else theme.text.copy(alpha = 0.9f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - } - - // Metadata row - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), verticalAlignment = Alignment.CenterVertically ) { Text( - text = model.providerName.lowercase(), + text = model.providerName, style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, color = theme.textMuted ) model.contextWindow?.let { ctx -> if (ctx > 0) { Text( - text = "·", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted - ) - Text( - text = "${ctx / 1000}k", + text = "· ${formatContextWindow(ctx)}", style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, color = theme.textMuted ) } } - if (model.hasReasoning) { - val reasoningLabel = if (model.reasoningEfforts.isEmpty()) { - "[R]" - } else { - "[R:${model.reasoningEfforts.joinToString("/")}]" - } - Text( - text = reasoningLabel, - style = MaterialTheme.typography.labelSmall, - color = theme.warning - ) - } - if (model.hasTools) { - Text( - text = "[T]", - style = MaterialTheme.typography.labelSmall, - color = theme.accent - ) - } } } - // Favorite button + // Capability badges + favorite, right-aligned. + if (model.hasReasoning) { + Text( + text = "[R]", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted + ) + } + if (model.hasTools) { + Text( + text = "[T]", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted + ) + } IconButton( onClick = onToggleFavorite, modifier = Modifier @@ -733,3 +814,13 @@ private fun TuiModelListItem( } } } + +private const val CONTEXT_MILLION = 1_000_000 +private const val CONTEXT_THOUSAND = 1_000 + +/** `200K`, `1M`, `64K` — uppercase, matching design 06. */ +private fun formatContextWindow(ctx: Int): String = when { + ctx >= CONTEXT_MILLION -> "${ctx / CONTEXT_MILLION}M" + ctx >= CONTEXT_THOUSAND -> "${ctx / CONTEXT_THOUSAND}K" + else -> ctx.toString() +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt index f920d7af..22e356f5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties @@ -41,6 +43,7 @@ import dev.blazelight.p4oc.ui.theme.Spacing * Shows filtered list of available slash commands */ @Composable +@Suppress("LongMethod", "FunctionNaming") fun SlashCommandsPopup( state: SlashCommandsPopupState, callbacks: SlashCommandsPopupCallbacks, @@ -53,7 +56,8 @@ fun SlashCommandsPopup( LaunchedEffect(activeIndex) { if (activeIndex >= 0) { - listState.animateScrollToItem(activeIndex) + // +1 for the pinned "SLASH COMMANDS" header item at index 0. + listState.animateScrollToItem(activeIndex + 1) } } @@ -79,8 +83,9 @@ fun SlashCommandsPopup( LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, - contentPadding = PaddingValues(vertical = Spacing.hairline) + contentPadding = PaddingValues(bottom = Spacing.hairline) ) { + item { SlashCommandsHeader() } when { state.isLoading && filteredCommands.isEmpty() -> { item { SlashCommandMessage(text = stringResource(R.string.slash_commands_loading)) } @@ -221,6 +226,24 @@ private fun SlashCommandError( } } +/** Uppercase, letter-spaced panel header — `SLASH COMMANDS`, matching the design. */ +@Composable +@Suppress("FunctionNaming") +private fun SlashCommandsHeader() { + val theme = LocalOpenCodeTheme.current + Column(modifier = Modifier.background(theme.background)) { + Text( + text = "SLASH COMMANDS", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + letterSpacing = 1.sp, + color = theme.textMuted, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) + HorizontalDivider(color = theme.border, thickness = Sizing.strokeThin) + } +} + @Composable private fun SlashCommandItem( command: Command, @@ -228,6 +251,7 @@ private fun SlashCommandItem( onClick: () -> Unit ) { val theme = LocalOpenCodeTheme.current + val (glyph, glyphColor) = slashCommandIcon(command) Row( modifier = Modifier @@ -235,47 +259,66 @@ private fun SlashCommandItem( .testTag("slash_command_${command.name}") .background(if (active) theme.backgroundElement else theme.background) .clickable(onClick = onClick, role = Role.Button) - .padding(horizontal = Spacing.md, vertical = Spacing.xs), + .padding(horizontal = Spacing.md, vertical = Spacing.sm), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + horizontalArrangement = Arrangement.spacedBy(Spacing.md) ) { - Column(modifier = Modifier.weight(1f)) { + Text( + text = glyph, + color = glyphColor, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.widthIn(min = Sizing.iconMd) + ) + Text( + text = "/${command.name}", + color = theme.primary, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(min = Sizing.panelWidthSm) + ) + command.description?.let { description -> Text( - text = "/${command.name}", - color = theme.accent, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, + text = description, + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - command.description?.let { description -> - Text( - text = description, - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } - Text( - text = slashCommandSourceCompactLabel(command.source), - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = command.source.badgeColor(), - maxLines = 1 - ) } } +/** Glyph + accent color for a command, keyed on well-known names then falling back to source. */ @Composable -private fun CommandSource.badgeColor() = when (this) { - CommandSource.BuiltIn -> LocalOpenCodeTheme.current.textMuted - CommandSource.Skill -> LocalOpenCodeTheme.current.info - CommandSource.Mcp -> LocalOpenCodeTheme.current.warning - CommandSource.Custom -> LocalOpenCodeTheme.current.accent - CommandSource.Subtask -> LocalOpenCodeTheme.current.info +@Suppress("CyclomaticComplexMethod") +private fun slashCommandIcon(command: Command): Pair { + val theme = LocalOpenCodeTheme.current + return when (command.name.lowercase()) { + "model", "models" -> "◆" to theme.secondary + "agent", "agents", "mode" -> "△" to theme.info + "diff", "changes" -> "±" to theme.warning + "undo", "revert" -> "↺" to theme.error + "redo" -> "↻" to theme.success + "share" -> "↗" to theme.info + "unshare" -> "⊘" to theme.textMuted + "summarize", "compact" -> "≡" to theme.warning + "clear", "new", "reset" -> "✕" to theme.textMuted + "init" -> "✦" to theme.accent + "help" -> "?" to theme.info + "editor", "edit" -> "✎" to theme.accent + else -> when (command.source) { + CommandSource.Skill -> "✧" to theme.info + CommandSource.Mcp -> "◇" to theme.warning + CommandSource.Custom -> "▸" to theme.accent + CommandSource.Subtask -> "▹" to theme.info + CommandSource.BuiltIn -> "▸" to theme.textMuted + } + } } internal fun slashCommandSourceCompactLabel(source: CommandSource): String = when (source) { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt index cb0a83db..e0455e19 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -23,6 +22,7 @@ import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource +import dev.blazelight.p4oc.ui.components.TuiBackButton import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator import dev.blazelight.p4oc.ui.components.TuiTextField @@ -435,17 +435,7 @@ private fun CommandArgumentsView( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = Spacing.md) ) { - IconButton( - onClick = onBack, - modifier = Modifier.size(Sizing.iconButtonSm) - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = theme.textMuted, - modifier = Modifier.size(Sizing.iconSm) - ) - } + TuiBackButton(onClick = onBack, description = stringResource(R.string.back)) Spacer(Modifier.width(Spacing.sm)) Text( text = "/${command.name}", diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/status/SessionStatusComponents.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/status/SessionStatusComponents.kt index 5b1dab40..ce9f8207 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/status/SessionStatusComponents.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/status/SessionStatusComponents.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -26,6 +27,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -118,11 +121,12 @@ fun SessionStatusDot( strokeWidth = Sizing.strokeThin, ) } else { - Icon( - imageVector = visual.icon ?: Icons.Default.Circle, - contentDescription = visual.contentDescription, - modifier = Modifier.size(size), - tint = visual.color, + // Design uses flat square status dots everywhere (no rounded circles). + Box( + modifier = Modifier + .size(size) + .background(visual.color, androidx.compose.ui.graphics.RectangleShape) + .semantics { contentDescription = visual.contentDescription }, ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt index 6e56bc3d..d312e211 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt @@ -276,32 +276,37 @@ private fun getToolCompactDescription(tool: Part.Tool): String { val input = tool.state.input val name = tool.toolName.lowercase() + // TUI `verb arg` form, matching design 05: `read SseClient.kt`, `grep "reconnect"`, `bash ./gradlew …`. return when { name in listOf("bash", "execute", "shell") -> { - extractParam(input, "command")?.take(60) ?: tool.toolName + val command = extractParam(input, "command")?.take(60) ?: return "bash" + "bash $command" } name in listOf("read", "read_file", "serena_read_file") -> { val path = extractParam(input, "filePath") ?: extractParam(input, "path") ?: extractParam(input, "relative_path") - val fileName = path?.substringAfterLast("/") ?: "file" - val lines = extractParam(input, "limit")?.toIntOrNull() - if (lines != null) "Read $fileName ($lines lines)" else "Read $fileName" + "read ${path?.substringAfterLast("/") ?: "file"}" } - name in listOf("edit", "write", "morph_edit_file", "serena_replace_content", "serena_create_text_file") -> { + name in listOf("edit", "morph_edit_file", "serena_replace_content") -> { val path = extractParam(input, "filePath") ?: extractParam(input, "path") ?: extractParam(input, "relative_path") - val fileName = path?.substringAfterLast("/") ?: "file" - "Modified $fileName" + "edit ${path?.substringAfterLast("/") ?: "file"}" + } + name in listOf("write", "serena_create_text_file") -> { + val path = extractParam(input, "filePath") + ?: extractParam(input, "path") + ?: extractParam(input, "relative_path") + "write ${path?.substringAfterLast("/") ?: "file"}" } name in listOf("glob", "find", "serena_find_file") -> { val pattern = extractParam(input, "pattern") ?: extractParam(input, "file_mask") - pattern?.let { "Glob $it" } ?: tool.toolName + pattern?.let { "glob $it" } ?: tool.toolName } name in listOf("grep", "search", "serena_search_for_pattern") -> { val pattern = extractParam(input, "pattern") ?: extractParam(input, "substring_pattern") - pattern?.take(40)?.let { "Search: $it" } ?: tool.toolName + pattern?.take(40)?.let { "grep \"$it\"" } ?: tool.toolName } else -> tool.toolName } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt index 6226fe69..d9b46055 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt @@ -3,6 +3,7 @@ package dev.blazelight.p4oc.ui.components.toolwidgets import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -259,23 +260,11 @@ private fun PendingApprovalButtonsInline( Row( modifier = Modifier .fillMaxWidth() - .background(theme.secondary.copy(alpha = 0.2f)) - .padding(horizontal = Spacing.md, vertical = Spacing.xs), - horizontalArrangement = Arrangement.spacedBy(Spacing.xs) + .background(theme.backgroundElement) + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { OutlinedButton( - onClick = onDeny, - modifier = Modifier - .weight(1f) - .heightIn(min = Sizing.minTouchTarget) - .semantics { contentDescription = "Deny permission $requestId" } - .testTag("tool_permission_deny_$requestId"), - contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), - shape = RectangleShape - ) { - Text(stringResource(R.string.deny), style = MaterialTheme.typography.labelSmall) - } - Button( onClick = onApprove, modifier = Modifier .weight(1f) @@ -283,7 +272,9 @@ private fun PendingApprovalButtonsInline( .semantics { contentDescription = "Allow permission once $requestId" } .testTag("tool_permission_allow_once_$requestId"), contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), - shape = RectangleShape + shape = RectangleShape, + border = BorderStroke(Sizing.strokeMd, theme.success), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.success) ) { Text(stringResource(R.string.allow), style = MaterialTheme.typography.labelSmall) } @@ -296,8 +287,24 @@ private fun PendingApprovalButtonsInline( .testTag("tool_permission_allow_always_$requestId"), contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), shape = RectangleShape, + border = BorderStroke(Sizing.strokeMd, theme.border), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.textMuted) ) { Text(stringResource(R.string.always_allow), style = MaterialTheme.typography.labelSmall) } + OutlinedButton( + onClick = onDeny, + modifier = Modifier + .weight(1f) + .heightIn(min = Sizing.minTouchTarget) + .semantics { contentDescription = "Deny permission $requestId" } + .testTag("tool_permission_deny_$requestId"), + contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), + shape = RectangleShape, + border = BorderStroke(Sizing.strokeMd, theme.error), + colors = ButtonDefaults.outlinedButtonColors(contentColor = theme.error) + ) { + Text(stringResource(R.string.deny), style = MaterialTheme.typography.labelSmall) + } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt index 7e97c79c..40cc9c55 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt @@ -68,8 +68,8 @@ fun NavGraph( ) { composable(Screen.Setup.route) { SetupScreen( - onSetupComplete = { - navController.navigate(Screen.Server.route) { + onConnected = { + navController.navigate(Screen.Sessions.route) { popUpTo(Screen.Setup.route) { inclusive = true } } } @@ -83,11 +83,6 @@ fun NavGraph( popUpTo(Screen.Server.route) { inclusive = true } } }, - onNavigateToProjects = { - navController.navigate(Screen.Sessions.route) { - popUpTo(Screen.Server.route) { inclusive = true } - } - }, onSettings = { navController.navigate(Screen.Settings.route) } @@ -98,13 +93,14 @@ fun NavGraph( val serverConnectionRegistry: ServerConnectionRegistry = koinInject() serverScreen( onNavigateToSessions = { navController.popBackStack() }, - onNavigateToProjects = { navController.popBackStack() }, onSettings = { navController.navigate(Screen.Settings.route) }, autoReconnect = false, + showManualFormInitially = true, onConnectSavedServer = { saved -> serverConnectionRegistry.connect(saved) navController.popBackStack() }, + onNavigateBack = { navController.popBackStack() }, ) } @@ -115,6 +111,9 @@ fun NavGraph( onNotificationRouteConsumed = onNotificationRouteConsumed, onDisconnect = { navController.navigate(Screen.ServerManagement.route) + }, + onSettings = { + navController.navigate(Screen.Settings.route) } ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index e3fa8027..df61ad3d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R @@ -131,6 +132,7 @@ fun ChatScreen( val availableAgents by viewModel.modelAgentManager.availableAgents.collectAsStateWithLifecycle() val selectedAgent by viewModel.modelAgentManager.selectedAgent.collectAsStateWithLifecycle() val availableModels by viewModel.modelAgentManager.availableModels.collectAsStateWithLifecycle() + val providerNames by viewModel.modelAgentManager.providerNames.collectAsStateWithLifecycle() val selectedModel by viewModel.modelAgentManager.selectedModel.collectAsStateWithLifecycle() val selectedReasoningEffort by viewModel.modelAgentManager.selectedReasoningEffort.collectAsStateWithLifecycle() val favoriteModels by viewModel.modelAgentManager.favoriteModels.collectAsStateWithLifecycle() @@ -194,6 +196,13 @@ fun ChatScreen( saver = ChatScrollRestorationState.Saver ) { ChatScrollRestorationState() } val messageBlocks = remember(messages, uiState.isBusy) { groupMessagesIntoBlocks(messages, uiState.isBusy) } + // Context-window usage for the composer meter — from the newest assistant reply's token totals. + val usedContextTokens = remember(messages) { + ( + messages.lastOrNull { it.message is dev.blazelight.p4oc.domain.model.Message.Assistant } + ?.message as? dev.blazelight.p4oc.domain.model.Message.Assistant + )?.tokens?.let { it.input + it.output + it.reasoning + it.cacheRead + it.cacheWrite } + } val searchMatches = remember(messageBlocks, scrollRestorationState.searchQuery) { findChatMatches(messageBlocks, scrollRestorationState.searchQuery) } @@ -350,6 +359,11 @@ fun ChatScreen( .imePadding() .navigationBarsPadding() ) { + // Hairline separating the flat composer from the chat above it. + HorizontalDivider( + color = LocalOpenCodeTheme.current.border, + thickness = Sizing.strokeThin + ) ModelAgentSelectorBar( availableAgents = availableAgents, selectedAgent = selectedAgent, @@ -361,7 +375,9 @@ fun ChatScreen( onReasoningEffortSelected = viewModel.modelAgentManager::selectReasoningEffort, favoriteModels = favoriteModels, recentModels = recentModels, - onToggleFavorite = viewModel.modelAgentManager::toggleFavoriteModel + onToggleFavorite = viewModel.modelAgentManager::toggleFavoriteModel, + usedContextTokens = usedContextTokens, + providerNames = providerNames, ) ChatInputBar( value = uiState.inputText, @@ -683,27 +699,38 @@ private fun ChatTopBar( var showOverflow by remember { mutableStateOf(false) } TuiTopBar( - title = title, onNavigateBack = onBack, - actions = { - // Compact status: connection dot + branch (no 40dp boxes) - ConnectionDot(state = connectionState) - branchName?.let { branch -> - Text( - text = "${stringResource(R.string.vcs_branch_prefix)} $branch", - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace - ), - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .widthIn(max = Sizing.panelWidthSm) // 80dp — tighter - .padding(start = Spacing.xxs) - ) + titleContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + ConnectionDot(state = connectionState) + Spacer(Modifier.width(Spacing.xs)) + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold + ) + ) + } + // Branch sits below the title so it gets full width instead of + // being squeezed/truncated in the actions row. + branchName?.let { branch -> + Text( + text = "${stringResource(R.string.vcs_branch_prefix)} $branch", + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace + ), + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - - Spacer(Modifier.width(Spacing.xs)) + }, + title = title, + actions = { // Todo count — only when there are todos (TUI glyph, no rounded badge) if (todoCount > 0) { IconButton( @@ -723,13 +750,13 @@ private fun ChatTopBar( Box { IconButton( onClick = { showOverflow = true }, - modifier = Modifier.size(Sizing.iconButtonMd).testTag("chat_overflow_button") + modifier = Modifier.size(Sizing.iconButtonLg).testTag("chat_overflow_button") ) { Text( text = "≡", - color = theme.accent, + color = theme.text, fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.headlineSmall ) } DropdownMenu( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt index cc740552..dce5f3c9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt @@ -42,6 +42,10 @@ class ModelAgentManager( private val _availableModels = MutableStateFlow>>(emptyList()) val availableModels: StateFlow>> = _availableModels.asStateFlow() + /** Provider id -> the provider's own display name ("llmproxy" -> "LLM Proxy"). */ + private val _providerNames = MutableStateFlow>(emptyMap()) + val providerNames: StateFlow> = _providerNames.asStateFlow() + private val _selectedModel = MutableStateFlow(null) val selectedModel: StateFlow = _selectedModel.asStateFlow() @@ -141,8 +145,10 @@ class ModelAgentManager( when (result) { is ApiResult.Success -> { val models = mutableListOf>() + val names = mutableMapOf() result.data.connected.forEach { providerId -> val provider = result.data.all.find { it.id == providerId } + provider?.name?.takeIf { it.isNotBlank() }?.let { names[providerId] = it } provider?.models?.values?.forEach { model -> models.add(providerId to model) } @@ -166,6 +172,7 @@ class ModelAgentManager( } } == true _availableModels.value = models + _providerNames.value = names if (!selectedModelFromAgent && (!selectedModelExplicitly || !currentSelectionIsAvailable)) { _selectedModel.value = fallbackModel selectedModelExplicitly = false diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/DiffViewerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/DiffViewerScreen.kt index ffad11c8..c89263dc 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/DiffViewerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/DiffViewerScreen.kt @@ -55,24 +55,43 @@ fun DiffViewerScreen( title = "", onNavigateBack = onNavigateBack, titleContent = { - Column { + Text( + text = displayFileName.ifEmpty { stringResource(R.string.diff_viewer_title) }, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold + ), + fontFamily = FontFamily.Monospace, + color = theme.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + }, + actions = { + // +N −N diff stats (design 09) + val additions = files.sumOf { f -> + f.hunks.sumOf { h -> h.lines.count { it.type == ParsedDiffLineType.ADDED } } + } + val deletions = files.sumOf { f -> + f.hunks.sumOf { h -> h.lines.count { it.type == ParsedDiffLineType.REMOVED } } + } + if (additions > 0) { Text( - text = "[ ${stringResource(R.string.diff_viewer_title)} ]", - style = MaterialTheme.typography.titleMedium, + text = "+$additions", + style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace, - color = theme.text + color = theme.success, ) - if (displayFileName.isNotEmpty()) { - Text( - text = displayFileName, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted - ) - } + Spacer(Modifier.width(Spacing.xs)) + } + if (deletions > 0) { + Text( + text = "−$deletions", + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = theme.error, + ) + Spacer(Modifier.width(Spacing.xs)) } - }, - actions = { IconButton( onClick = { viewMode = if (viewMode == DiffViewMode.UNIFIED) { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt index d6521808..18912a18 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt @@ -698,7 +698,7 @@ private fun TuiFileItem( onClick: () -> Unit, ) { val theme = LocalOpenCodeTheme.current - val (icon, iconColor) = getFileIcon(file) + val iconColor = getFileIcon(file).second val gitStatusColor = getGitStatusColor(file.gitStatus) val clipboardManager = LocalClipboardManager.current val haptic = LocalHapticFeedback.current @@ -772,25 +772,22 @@ private fun TuiFileItem( horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalAlignment = Alignment.CenterVertically ) { - // File type indicator - Text( - text = if (file.isDirectory) "▸" else " ", - style = MaterialTheme.typography.bodyMedium, - color = theme.accent - ) - - // Icon - Icon( - imageVector = icon, - contentDescription = null, - tint = gitStatusColor ?: iconColor, - modifier = Modifier.size(Sizing.iconSm) - ) + // Type glyph (design 07): ▸ folders in secondary, ■ files in type color + Box( + modifier = Modifier.width(Sizing.iconMd), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (file.isDirectory) "▸" else "■", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = if (file.isDirectory) theme.secondary else (gitStatusColor ?: iconColor), + ) + } // File name Text( text = file.name, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal, color = gitStatusColor ?: theme.text, modifier = Modifier.weight(1f), @@ -803,12 +800,12 @@ private fun TuiFileItem( TuiGitStatusBadge(status) } - // Directory indicator - if (file.isDirectory) { + // Trailing chevron — files open in the viewer + if (!file.isDirectory) { Text( - text = ">", - style = MaterialTheme.typography.bodyMedium, - color = theme.textMuted + text = "›", + style = MaterialTheme.typography.titleMedium, + color = theme.border ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index 36f5dc50..91b2e4ea 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -23,16 +24,17 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -46,33 +48,38 @@ 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.alpha +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.domain.server.WorkspaceKey -import dev.blazelight.p4oc.ui.screens.server.ServerConnectionStatus +import dev.blazelight.p4oc.ui.components.TuiBackButton +import dev.blazelight.p4oc.ui.components.status.SessionStatusDot import dev.blazelight.p4oc.ui.tabs.StartWorkSelection import dev.blazelight.p4oc.ui.tabs.StartWorkTarget import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.ProjectColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing -import dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme import java.util.concurrent.TimeUnit private const val RECENT_DAY_LIMIT = 30 private const val HOME_WORKSPACE_SHORTCUT_LIMIT = 3 -private const val DISABLED_FILTER_ALPHA = 0.5f +private const val CONNECTED_SERVER_LIST_THRESHOLD = 2 +private const val DASH_ON = 10f +private const val DASH_OFF = 8f data class HomeActions( val onBrowseSessions: (StartWorkTarget) -> Unit, val onBrowseAllSessions: () -> Unit = {}, @@ -80,6 +87,8 @@ data class HomeActions( val onOpenTerminal: (StartWorkTarget) -> Unit, val onChooseTarget: () -> Unit, val onManageServers: () -> Unit = {}, + val onRefresh: () -> Unit = {}, + val onSettings: () -> Unit = {}, val onFocusTab: (String) -> Unit = {}, val onResumeSession: (SessionPreview) -> Unit = {}, val onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, @@ -91,11 +100,11 @@ private data class HomeOverviewInput( val summary: HomeSummaryState, val enabledEndpointKeys: Set, val searchQuery: String, + val showAllWorkspaces: Boolean, val onToggleServer: (String) -> Unit, val onEnableAllServers: () -> Unit, val onSearchQueryChange: (String) -> Unit, - val showAllWorkspaces: Boolean, - val onShowAllWorkspacesChange: (Boolean) -> Unit, + val onToggleAllWorkspaces: () -> Unit, val onWorkspaceClick: (WorkspaceSummary) -> Unit, val actions: HomeActions, val listState: LazyListState, @@ -103,6 +112,7 @@ private data class HomeOverviewInput( private data class WorkspaceDetailInput( val workspace: WorkspaceSummary, + val serverConnected: Boolean, val openWork: List, val sessions: List, val actions: HomeActions, @@ -125,6 +135,9 @@ fun homeScreen( workspaceDetail( input = WorkspaceDetailInput( workspace = selected, + serverConnected = summary.servers.firstOrNull { + it.serverRef.endpointKey == selected.serverRef.endpointKey + }?.connectionState is ConnectionState.Connected, openWork = summary.openWork.filter { it.serverRef.endpointKey == selected.serverRef.endpointKey && it.workspaceKey == selected.workspaceKey @@ -141,32 +154,19 @@ fun homeScreen( ), modifier = modifier, ) - } else if (showAllWorkspaces) { - allWorkspacesScreen( - workspaces = summary.filteredHomeResults( - enabledEndpointKeys = summary.enabledEndpointKeys(disabledEndpointKeys), - query = "", - ).workspaces, - onWorkspaceClick = { - selectedWorkspace = it - actions.onWorkspaceSelected(it) - }, - onBack = { showAllWorkspaces = false }, - modifier = modifier, - ) } else { homeOverview( input = HomeOverviewInput( summary = summary, enabledEndpointKeys = summary.enabledEndpointKeys(disabledEndpointKeys), searchQuery = searchQuery, + showAllWorkspaces = showAllWorkspaces, onToggleServer = { endpointKey -> disabledEndpointKeys = disabledEndpointKeys.toggleMembership(endpointKey) }, onEnableAllServers = { disabledEndpointKeys = emptyList() }, onSearchQueryChange = { searchQuery = it }, - showAllWorkspaces = showAllWorkspaces, - onShowAllWorkspacesChange = { showAllWorkspaces = it }, + onToggleAllWorkspaces = { showAllWorkspaces = !showAllWorkspaces }, onWorkspaceClick = { selectedWorkspace = it actions.onWorkspaceSelected(it) @@ -182,33 +182,6 @@ fun homeScreen( } } -@Composable -private fun allWorkspacesScreen( - workspaces: List, - onWorkspaceClick: (WorkspaceSummary) -> Unit, - onBack: () -> Unit, - modifier: Modifier, -) { - LazyColumn( - modifier = modifier.fillMaxSize().testTag("home_all_workspaces"), - contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - item { textAction("‹ Home", "Back to recent workspaces", onBack) } - item { sectionLabel("All workspaces · ${workspaces.size}") } - item { - Column( - Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), - ) { - workspaces.forEach { workspace -> - workspaceShortcut(workspace, onWorkspaceClick, Modifier.fillMaxWidth()) - } - } - } - } -} - @Composable private fun homeOverview(input: HomeOverviewInput, modifier: Modifier) { val summary = input.summary @@ -230,59 +203,25 @@ private fun LazyListScope.homeOverviewContent( results: FilteredHomeResults, ) { val summary = input.summary - item { homeHeader(summary, input.actions.onManageServers) } + item { homeHeader(summary, input.actions) } item { homeSearchField(input.searchQuery, input.onSearchQueryChange) } - item { - serverFilters( - servers = summary.servers, - enabledEndpointKeys = input.enabledEndpointKeys, - searchActive = input.searchQuery.isNotBlank(), - onToggle = input.onToggleServer, - ) - } - if (input.searchQuery.isNotBlank() && input.enabledEndpointKeys.size < summary.servers.size) { - item { - Text( - globalSearchOverrideLabel(input.enabledEndpointKeys.size), - style = MaterialTheme.typography.labelSmall, - color = LocalOpenCodeTheme.current.textMuted, - maxLines = 2, - ) - } - } + item { Spacer(Modifier.height(Spacing.sm)) } if (summary.isLoading) { item { - infoCard("Loading existing work", "Sessions already loaded remain available while Home refreshes.") + infoCard("Refreshing", "Already-loaded work stays available.") } } if (summary.partialFailures.isNotEmpty()) { - item { infoCard("Some work is unavailable", summary.partialFailures.joinToString(" · ")) } + item { infoCard("Some servers did not respond", summary.partialFailures.joinToString(" · ")) } } - if (input.searchQuery.isBlank() && input.enabledEndpointKeys.isEmpty()) { - item { - infoCard( - "No servers enabled", - "Turn on one or more servers above to browse existing work.", - ) - } - item { - textAction( - label = "Select all servers", - description = "Include every saved server in Home browsing", - onClick = input.onEnableAllServers, - modifier = Modifier.testTag("home_enable_all_servers"), - ) - } - } else { - homeWorkspaces(input, results.workspaces) - HomeSessions(input, results.sessions) + // Workspaces first, then the sessions themselves, so resuming a session is one tap from Home. + homeWorkspaces(input, results.workspaces) + HomeSessions(input, results.sessions) + if (input.searchQuery.isBlank()) { + connectedServers(input, summary.servers) } } -private fun globalSearchOverrideLabel(enabledCount: Int): String = - "Global search includes every server · clear search to resume " + - "$enabledCount enabled server${if (enabledCount == 1) "" else "s"}" - @Composable private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { val theme = LocalOpenCodeTheme.current @@ -291,12 +230,16 @@ private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { value = query, onValueChange = onQueryChange, singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = theme.text), - cursorBrush = androidx.compose.ui.graphics.SolidColor(theme.accent), + textStyle = MaterialTheme.typography.labelMedium.copy( + color = theme.text, + fontFamily = FontFamily.Monospace, + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(theme.primary), modifier = Modifier .fillMaxWidth() - .height(Sizing.minTouchTarget) - .border(Sizing.strokeThin, theme.border, RectangleShape) + .height(Sizing.textFieldHeightSm) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape) .semantics { contentDescription = searchDescription } .testTag("home_search_field"), decorationBox = { field -> @@ -306,16 +249,16 @@ private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { ) { Text( "/", - style = MaterialTheme.typography.labelMedium, - color = theme.textMuted, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.primary, ) Spacer(Modifier.width(Spacing.xs)) Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { field() if (query.isEmpty()) { Text( - "Search every server, session, or workspace…", - style = MaterialTheme.typography.labelMedium, + "Search sessions and workspaces", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), color = theme.textMuted, maxLines = 1, ) @@ -327,23 +270,12 @@ private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { } private val HomeSessions: LazyListScope.(HomeOverviewInput, List) -> Unit = { input, sessions -> - item { - val scope = if (input.searchQuery.isNotBlank()) { - "all servers" - } else { - "${input.enabledEndpointKeys.size} server${if (input.enabledEndpointKeys.size == 1) "" else "s"}" - } - sectionLabel("Newest sessions · $scope · ${sessions.size}") - } + item { sectionLabel("Sessions · ${sessions.size}") } if (sessions.isEmpty()) { item { infoCard( - if (input.searchQuery.isNotBlank()) "No matching sessions" else "No sessions here", - if (input.searchQuery.isNotBlank()) { - "Try another search or server filter." - } else { - "Sessions with history will appear here for quick resume." - }, + if (input.searchQuery.isNotBlank()) "No matching sessions" else "No sessions yet", + if (input.searchQuery.isNotBlank()) "Try another search." else "Use + to start one.", ) } } else { @@ -370,301 +302,231 @@ private val HomeSessions: LazyListScope.(HomeOverviewInput, List } } -@Suppress("LongMethod") private fun LazyListScope.homeWorkspaces(input: HomeOverviewInput, filteredWorkspaces: List) { - item { sectionLabel(if (input.showAllWorkspaces) "All workspaces" else "Recent workspaces") } + val searching = input.searchQuery.isNotBlank() + val capped = searching || input.showAllWorkspaces + val visible = if (capped) filteredWorkspaces else filteredWorkspaces.take(HOME_WORKSPACE_SHORTCUT_LIMIT) + item { sectionLabel(if (searching) "Workspaces" else "Recent workspaces") } if (filteredWorkspaces.isEmpty()) { item { infoCard( - if (input.summary.isLoading) { - "Looking for workspaces" - } else if (input.searchQuery.isNotBlank()) { - "No matching workspaces" - } else { - "No resumable work" - }, - if (input.searchQuery.isNotBlank()) { - "Try another search or server filter." - } else { - "Choose another server filter, or use + to start something new." + when { + input.summary.isLoading -> "Looking for workspaces" + searching -> "No matching workspaces" + else -> "No workspaces yet" }, + if (searching) "Try another search." else "Use + to start something new.", ) } - } else if (input.showAllWorkspaces || input.searchQuery.isNotBlank()) { - item { - Column( - Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), - ) { - filteredWorkspaces.forEach { workspace -> - workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) - } - } - } - if (input.searchQuery.isBlank()) { - item { - textAction( - "Show recent only", - "Collapse the workspace list", - { input.onShowAllWorkspacesChange(false) }, - ) - } - } - } else { + return + } + item { dividedRows(visible) { workspace -> workspaceShortcut(workspace, input.onWorkspaceClick) } } + val hidden = filteredWorkspaces.size - visible.size + if (!searching && (hidden > 0 || input.showAllWorkspaces)) { item { - Column( - Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), - ) { - filteredWorkspaces.take(HOME_WORKSPACE_SHORTCUT_LIMIT).forEach { workspace -> - workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) - } - } - } - if (filteredWorkspaces.size > HOME_WORKSPACE_SHORTCUT_LIMIT) { - item { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "${HOME_WORKSPACE_SHORTCUT_LIMIT.coerceAtMost(filteredWorkspaces.size)} most recently used", - style = MaterialTheme.typography.labelSmall, - color = LocalOpenCodeTheme.current.textMuted, - ) - textAction( - "All ${filteredWorkspaces.size} workspaces ›", - "", - { input.onShowAllWorkspacesChange(true) }, - Modifier.testTag("home_all_workspaces_action"), - ) - } - } + homeMoreRow( + label = if (input.showAllWorkspaces) "− show less" else "+ $hidden more", + onClick = input.onToggleAllWorkspaces, + testTag = "home_workspaces_toggle", + ) } } } +/** Inline `+ N more` / `− show less` affordance under a capped Home list. */ @Composable -private fun homeHeader(summary: HomeSummaryState, onServers: () -> Unit) { +private fun homeMoreRow(label: String, onClick: () -> Unit, testTag: String) { + Text( + label, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = LocalOpenCodeTheme.current.accent, + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = Spacing.sm, vertical = Spacing.sm) + .testTag(testTag), + ) +} + +@Composable +private fun homeHeader(summary: HomeSummaryState, actions: HomeActions) { + val theme = LocalOpenCodeTheme.current Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text("[ Home ]", style = MaterialTheme.typography.titleMedium, color = LocalOpenCodeTheme.current.text) + Text( + "Home", + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.text, + ) Text( stringResource(R.string.home_summary_counts, summary.sessions.size, summary.workspaces.size), - style = MaterialTheme.typography.labelSmall, - color = LocalOpenCodeTheme.current.textMuted, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - textAction("Servers ›", "", onServers, Modifier.testTag("home_servers_action")) + homeHeaderIconButton(Icons.Default.Refresh, "Refresh", actions.onRefresh, "home_refresh") + homeHeaderIconButton(Icons.Default.Add, "Add server", actions.onManageServers, "home_add_server") + homeHeaderIconButton(Icons.Default.Settings, "Settings", actions.onSettings, "home_settings") } } @Composable -private fun serverFilters( - servers: List, - enabledEndpointKeys: Set, - searchActive: Boolean, - onToggle: (String) -> Unit, +private fun homeHeaderIconButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + onClick: () -> Unit, + testTag: String, ) { - Column( - Modifier.fillMaxWidth().testTag("home_server_filters"), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + val theme = LocalOpenCodeTheme.current + Surface( + onClick = onClick, + shape = RectangleShape, + color = theme.background, + modifier = Modifier + .size(Sizing.minTouchTarget) + .semantics { contentDescription = description } + .testTag(testTag), ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - "Servers · ${enabledEndpointKeys.size}/${servers.size} on", - style = MaterialTheme.typography.labelSmall, - color = LocalOpenCodeTheme.current.textMuted, - ) - if (servers.size > 1) { - Text( - "Swipe ›", - style = MaterialTheme.typography.labelSmall, - color = LocalOpenCodeTheme.current.primary, - ) - } - } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(Spacing.xs), - contentPadding = PaddingValues(end = Spacing.xl), - modifier = Modifier.fillMaxWidth(), - ) { - items(servers, key = { it.serverRef.endpointKey }) { server -> - serverToggleCard( - server = server, - enabled = server.serverRef.endpointKey in enabledEndpointKeys, - searchActive = searchActive, - onToggle = { onToggle(server.serverRef.endpointKey) }, - ) - } + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = theme.textMuted, modifier = Modifier.size(Sizing.iconMd)) } } } -@Composable -private fun serverToggleCard( - server: ServerSummary, - enabled: Boolean, - searchActive: Boolean, - onToggle: () -> Unit, +private fun LazyListScope.connectedServers( + input: HomeOverviewInput, + servers: List, ) { - val theme = LocalOpenCodeTheme.current - val status = server.connectionState.toServerStatus() - val accessibilityDescription = remember(server, status) { - "${server.displayName}, ${status.visualLabel}, ${server.sessionCount} sessions, " + - serverEndpointDetail(server) + val connected = connectedServersForLauncher(servers) + if (connected.isEmpty()) return + item { sectionLabel("Connected servers · ${connected.size}") } + items(connected, key = { "connected_${it.serverRef.endpointKey}" }) { server -> + serverLauncherRow(server, input.actions.onManageServers) } +} + +internal fun connectedServersForLauncher(servers: List): List = + servers.filter { it.connectionState is ConnectionState.Connected } + .takeIf { it.size > CONNECTED_SERVER_LIST_THRESHOLD } + .orEmpty() + +@Composable +private fun serverLauncherRow(server: ServerSummary, onOpen: () -> Unit) { + val theme = LocalOpenCodeTheme.current Surface( + onClick = onOpen, shape = RectangleShape, color = theme.backgroundPanel, modifier = Modifier - .width(Sizing.serverFilterCardWidth) - .alpha(if (searchActive) DISABLED_FILTER_ALPHA else 1f) - .then( - if (enabled && !searchActive) { - Modifier.border(Sizing.strokeMd, theme.primary, RectangleShape) - } else { - Modifier - }, - ) - .toggleable( - value = enabled, - enabled = !searchActive, - role = Role.Checkbox, - onValueChange = { onToggle() }, - ) - .semantics { - stateDescription = if (searchActive) { - "${if (enabled) "On" else "Off"}; saved filter paused during global search" - } else if (enabled) { - "On" - } else { - "Off" - } - contentDescription = accessibilityDescription - } - .testTag("home_server_toggle_${server.serverRef.endpointKey}"), + .fillMaxWidth() + .testTag("home_connected_server_${server.serverRef.endpointKey}"), ) { - serverToggleCardContent(server, status, enabled, theme) - } -} - -@Composable -private fun serverToggleCardContent( - server: ServerSummary, - status: ServerConnectionStatus, - enabled: Boolean, - theme: OpenCodeTheme, -) { - Column(Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.fillMaxWidth().height(Sizing.listItemHeightSm).padding(horizontal = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { Box( Modifier.size(Sizing.indicatorDot) - .background(status.dotColor(theme), CircleShape) - .semantics { contentDescription = status.contentDescription }, + .background(theme.success, RectangleShape) + .semantics { contentDescription = "Connected server" }, ) - Spacer(Modifier.width(Spacing.xs)) Text( server.displayName, - style = MaterialTheme.typography.labelMedium, - color = if (enabled) theme.text else theme.textMuted, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) Text( - if (enabled) "ON" else "OFF", - style = MaterialTheme.typography.labelSmall, - color = if (enabled) theme.primary else theme.textMuted, - ) - } - Row { - Text( - serverEndpointDetail(server), - style = MaterialTheme.typography.labelSmall, + "${server.sessionCount} sessions", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), ) - Text("${server.sessionCount}", style = MaterialTheme.typography.labelSmall, color = theme.textMuted) + Text("›", style = MaterialTheme.typography.labelMedium, color = theme.border) } } } -private fun serverEndpointDetail(server: ServerSummary): String = - server.serverRef.endpointKey.removePrefix("http://").removePrefix("https://") +/** Colored project pill (dark text on a per-project color), shared by rows and the tree. */ +@Composable +private fun projectPill(workspace: WorkspaceSummary) { + val key = "${workspace.serverRef.endpointKey}:${workspace.workspaceKey}" + Surface(shape = RectangleShape, color = ProjectColors.colorForProject(key)) { + Text( + workspace.workspaceKey.displayLabel(), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = ProjectColors.textColorForProject(key), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = Sizing.chipMaxWidth) + .padding(horizontal = Spacing.sm, vertical = Spacing.xxs), + ) + } +} -private fun ConnectionState.toServerStatus(): ServerConnectionStatus = when (this) { - ConnectionState.Connected -> ServerConnectionStatus.CONNECTED - ConnectionState.Connecting -> ServerConnectionStatus.CONNECTING - ConnectionState.Disconnected -> ServerConnectionStatus.DISCONNECTED - is ConnectionState.Error -> ServerConnectionStatus.ERROR +/** Renders [items] as a single continuous panel, each row separated by a thin divider. */ +@Composable +private fun dividedRows(items: List, row: @Composable (T) -> Unit) { + val theme = LocalOpenCodeTheme.current + Column(Modifier.fillMaxWidth()) { + items.forEachIndexed { index, value -> + row(value) + if (index != items.lastIndex) { + HorizontalDivider(thickness = Sizing.strokeThin, color = theme.borderSubtle) + } + } + } } @Composable private fun workspaceShortcut( workspace: WorkspaceSummary, onOpen: (WorkspaceSummary) -> Unit, - modifier: Modifier, + modifier: Modifier = Modifier.fillMaxWidth(), ) { val theme = LocalOpenCodeTheme.current Surface( onClick = { onOpen(workspace) }, shape = RectangleShape, color = theme.backgroundPanel, - modifier = modifier.border(Sizing.strokeThin, theme.border, RectangleShape), + modifier = modifier, ) { Row( - Modifier.height(Sizing.listItemHeightSm), + Modifier + .fillMaxWidth() + .height(Sizing.listItemHeightMd) + .padding(horizontal = Spacing.sm), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - Box( - Modifier - .width(Sizing.strokeThick) - .height(Sizing.listItemHeightSm) - .background(ProjectColors.colorForProject("server:${workspace.serverRef.endpointKey}")), - ) - Column( - Modifier.weight(1f).padding(horizontal = Spacing.xs), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text( - workspace.workspaceKey.displayLabel(), - style = MaterialTheme.typography.labelMedium, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } + projectPill(workspace) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { Text( - workspace.workspaceKey.detailLabel(), - style = MaterialTheme.typography.labelSmall, + "${workspace.sessionCount} ${if (workspace.sessionCount == 1) "session" else "sessions"}" + + " · ${recency(workspace.mostRecentAt)}", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + workspace.serverRef.displayName, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), color = theme.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - Text( - "${workspace.sessionCount} ›", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - maxLines = 1, - modifier = Modifier.padding(end = Spacing.xxs), - ) + Text("›", style = MaterialTheme.typography.labelMedium, color = theme.border) } } } @@ -787,81 +649,121 @@ private fun SessionPresence.statusColor( SessionPresence.IDLE, SessionPresence.BACKGROUND -> theme.textMuted } -private fun ServerConnectionStatus.dotColor( - theme: OpenCodeTheme, -): Color = when (this) { - ServerConnectionStatus.CONNECTED -> theme.success - ServerConnectionStatus.CONNECTING -> theme.accent - ServerConnectionStatus.AVAILABLE -> theme.success - ServerConnectionStatus.DISCONNECTED -> theme.textMuted - ServerConnectionStatus.ERROR -> theme.error -} - -private val ServerConnectionStatus.contentDescription: String - get() = when (this) { - ServerConnectionStatus.CONNECTED -> "Connected server" - ServerConnectionStatus.CONNECTING -> "Server connecting" - ServerConnectionStatus.AVAILABLE -> "Server available" - ServerConnectionStatus.DISCONNECTED -> "Server disconnected" - ServerConnectionStatus.ERROR -> "Server connection error" - } - -private val ServerConnectionStatus.visualLabel: String - get() = when (this) { - ServerConnectionStatus.CONNECTED -> "online" - ServerConnectionStatus.CONNECTING -> "connecting" - ServerConnectionStatus.AVAILABLE -> "available" - ServerConnectionStatus.DISCONNECTED -> "offline" - ServerConnectionStatus.ERROR -> "error" - } - @Composable private fun workspaceDetail( input: WorkspaceDetailInput, modifier: Modifier, ) { + val theme = LocalOpenCodeTheme.current val workspace = input.workspace val target = StartWorkTarget(workspace.serverRef, workspace.workspaceKey) - LazyColumn( - modifier = modifier.fillMaxSize().testTag("home_workspace_detail"), - contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - item { - textAction( - label = "← Home", - description = "Back to the previous Home filter and position", - onClick = input.onBack, - modifier = Modifier.testTag("home_workspace_detail_back"), - ) - } - item { - infoCard( - workspace.workspaceKey.displayLabel(), - "${workspace.workspaceKey.detailLabel()}\n" + - "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName}", - ) + Column(modifier.fillMaxSize().testTag("home_workspace_detail")) { + workspaceDetailHeader(input) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + item { workspaceDetailCard(workspace) } + item { sectionLabel("Open work") } + workspaceOpenWork(input) + item { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + sectionLabel("Sessions") + Text( + "+ new", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.accent, + modifier = Modifier + .clickable(role = Role.Button) { input.actions.onStartScopedWork(target) } + .padding(horizontal = Spacing.xs, vertical = Spacing.xxs) + .testTag("home_workspace_detail_new"), + ) + } + } + workspaceSessions(input) + item { Spacer(Modifier.navigationBarsPadding()) } } - item { sectionLabel("Open work") } - workspaceOpenWork(input) - item { sectionLabel("Sessions in this workspace") } - workspaceSessions(input) - item { sectionLabel("Start new work") } - item { - textAction( - label = "New chat, Files, or Terminal", - description = "Create work through the shared coordinator in this exact workspace", - onClick = { input.actions.onStartScopedWork(target) }, + } +} + +@Composable +private fun workspaceDetailHeader(input: WorkspaceDetailInput) { + val theme = LocalOpenCodeTheme.current + Row( + Modifier.fillMaxWidth().padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + TuiBackButton( + onClick = input.onBack, + description = "Back to Home", + modifier = Modifier.testTag("home_workspace_detail_back"), + ) + Box( + Modifier + .size(Sizing.indicatorDotActive) + .background(if (input.serverConnected) theme.success else theme.textMuted, RectangleShape) + .semantics { + contentDescription = if (input.serverConnected) "Connected" else "Disconnected" + }, + ) + Text( + input.workspace.workspaceKey.displayLabel(), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = theme.textMuted, + modifier = Modifier + .clickable(role = Role.Button, onClick = input.actions.onRefresh) + .padding(Spacing.xs) + .size(Sizing.iconMd), + ) + } +} + +@Composable +private fun workspaceDetailCard(workspace: WorkspaceSummary) { + val theme = LocalOpenCodeTheme.current + Surface(shape = RectangleShape, color = theme.backgroundElement, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + projectPill(workspace) + Text( + "${workspace.serverRef.badgeLabel} · ${workspace.serverRef.displayName}", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + workspace.workspaceKey.detailLabel(), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } } -private fun androidx.compose.foundation.lazy.LazyListScope.workspaceOpenWork(input: WorkspaceDetailInput) { +private fun LazyListScope.workspaceOpenWork(input: WorkspaceDetailInput) { if (input.openWork.isEmpty()) { - item { - infoCard("Nothing open", "Existing tabs for this exact workspace appear here.") - } + item { dashedEmptyCard("Nothing open", "Tabs for this workspace show up here") } } else { items(input.openWork, key = { it.tabId }) { work -> openWorkCard(work) { input.actions.onFocusTab(work.tabId) } @@ -869,21 +771,80 @@ private fun androidx.compose.foundation.lazy.LazyListScope.workspaceOpenWork(inp } } -private fun androidx.compose.foundation.lazy.LazyListScope.workspaceSessions(input: WorkspaceDetailInput) { +private fun LazyListScope.workspaceSessions(input: WorkspaceDetailInput) { if (input.sessions.isEmpty()) { - item { - infoCard( - "No sessions", - "No resumable sessions were found for this exact server and workspace.", - ) - } + item { dashedEmptyCard("No sessions", "Start one with + new") } } else { items(input.sessions, key = { it.sessionId.value }) { session -> - sessionRow(session = session, onResume = { input.actions.onResumeSession(session) }) + workspaceSessionRow(session) { input.actions.onResumeSession(session) } + } + } +} + +@Composable +private fun workspaceSessionRow(session: SessionPreview, onResume: () -> Unit) { + val theme = LocalOpenCodeTheme.current + Surface( + onClick = onResume, + shape = RectangleShape, + color = theme.backgroundElement, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + SessionStatusDot(session.status, size = Sizing.indicatorDotActive) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + session.title, + style = MaterialTheme.typography.bodyMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${session.status.label()} · ${recency(session.updatedAt)}", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + maxLines = 1, + ) + } + SessionWorkspaceLabel(session, null) + } + } +} + +@Composable +private fun dashedEmptyCard(title: String, body: String) { + val theme = LocalOpenCodeTheme.current + Row( + Modifier + .fillMaxWidth() + .dashedBorder(theme.borderSubtle) + .padding(Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text("⌀", style = MaterialTheme.typography.bodyMedium, color = theme.textMuted) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text(title, style = MaterialTheme.typography.labelMedium, color = theme.textMuted) + Text(body, style = MaterialTheme.typography.labelSmall, color = theme.textMuted) } } } +private fun Modifier.dashedBorder(color: Color): Modifier = drawBehind { + drawRect( + color = color, + style = Stroke( + width = Sizing.strokeMd.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(DASH_ON, DASH_OFF), 0f), + ), + ) +} + @Composable private fun openWorkCard(work: OpenWorkSummary, onFocus: () -> Unit) { val icon = when (work.type) { @@ -924,19 +885,6 @@ private fun infoCard(title: String, body: String) { } } -@Composable -private fun textAction(label: String, description: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - val theme = LocalOpenCodeTheme.current - Surface(onClick = onClick, shape = RectangleShape, color = theme.background, modifier = modifier) { - Column(Modifier.padding(Spacing.xs)) { - Text(label, style = MaterialTheme.typography.labelMedium, color = theme.accent) - if (description.isNotEmpty()) { - Text(description, style = MaterialTheme.typography.labelSmall, color = theme.textMuted) - } - } - } -} - @Composable private fun compactAction(label: String, onClick: () -> Unit) { Surface(onClick = onClick, shape = RectangleShape, color = LocalOpenCodeTheme.current.background) { @@ -952,7 +900,10 @@ private fun compactAction(label: String, onClick: () -> Unit) { @Composable private fun sectionLabel(text: String) = Text( text.uppercase(), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 1.sp, + ), color = LocalOpenCodeTheme.current.textMuted, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerDiscoveredSection.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerDiscoveredSection.kt new file mode 100644 index 00000000..b67e7201 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerDiscoveredSection.kt @@ -0,0 +1,143 @@ +package dev.blazelight.p4oc.ui.screens.server + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.core.network.DiscoveredServer +import dev.blazelight.p4oc.core.network.DiscoveryState +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize + +private const val SCAN_PULSE_MIN_ALPHA = 0.35f +private const val SCAN_PULSE_DURATION_MS = 700 + +/** + * Nearby-server discovery (design 03): flat scanning row + "use" rows, no boxed panel. + */ +@Composable +internal fun discoveredServersSection( + servers: List, + discoveryState: DiscoveryState, + isConnecting: Boolean, + onServerClick: (DiscoveredServer) -> Unit, +) { + Column { + if (discoveryState == DiscoveryState.SCANNING) { + scanningRow() + Spacer(Modifier.height(Spacing.md)) + } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + servers.forEach { server -> + discoveredServerRow(server, isConnecting, onServerClick) + } + } + } +} + +@Composable +private fun discoveredServerRow( + server: DiscoveredServer, + isConnecting: Boolean, + onServerClick: (DiscoveredServer) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .background(theme.backgroundPanel, RectangleShape) + .clickable(enabled = !isConnecting, role = Role.Button) { + onServerClick(server) + } + .testTag("discovered_server_${server.serviceName}") + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.mdLg), + ) { + Box( + modifier = Modifier + .size(Sizing.indicatorDotActive) + .background(theme.info, RectangleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.serviceName, + color = theme.text, + fontSize = TuiCodeFontSize.xl, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${server.host}:${server.port}", + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = stringResource(R.string.server_use), + color = theme.secondary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + ) + } +} + +@Composable +private fun scanningRow() { + val theme = LocalOpenCodeTheme.current + val transition = rememberInfiniteTransition(label = "scanning") + val alpha by transition.animateFloat( + initialValue = SCAN_PULSE_MIN_ALPHA, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(SCAN_PULSE_DURATION_MS), RepeatMode.Reverse), + label = "scanPulse", + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + CircularProgressIndicator( + modifier = Modifier.size(Sizing.iconXxs), + color = theme.warning.copy(alpha = alpha), + strokeWidth = Sizing.strokeThick, + ) + Text( + text = stringResource(R.string.setup_scanning_nearby), + color = theme.warning, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + ) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index f78e5def..8965e692 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -6,15 +6,15 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -22,16 +22,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.datastore.SavedServer @@ -41,12 +45,15 @@ import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.core.network.toServerRef import dev.blazelight.p4oc.ui.components.TuiConfirmDialog -import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator -import dev.blazelight.p4oc.ui.components.status.serverStatusIndicator +import dev.blazelight.p4oc.ui.components.TuiSectionHeader +import dev.blazelight.p4oc.ui.components.TuiSwitch +import dev.blazelight.p4oc.ui.components.TuiTopBar +import dev.blazelight.p4oc.ui.components.status.serverStatusVisual import dev.blazelight.p4oc.ui.tabs.TabManager import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @@ -55,10 +62,11 @@ import org.koin.compose.koinInject @Composable fun serverScreen( onNavigateToSessions: () -> Unit, - onNavigateToProjects: () -> Unit, onSettings: () -> Unit, autoReconnect: Boolean = true, + showManualFormInitially: Boolean = false, onConnectSavedServer: ((SavedServer) -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, ) { val viewModel: ServerViewModel = koinViewModel() val theme = LocalOpenCodeTheme.current @@ -73,7 +81,9 @@ fun serverScreen( val openTabsByEndpoint = tabs.filterNot { it.isPinnedHome }.groupBy { it.serverEndpointKey } val inventory = remember(uiState, registryStates) { buildServerInventory(uiState, registryStates) } var showManualForm by rememberSaveable { - mutableStateOf(uiState.savedServers.isEmpty() && uiState.discoveredServers.isEmpty()) + mutableStateOf( + showManualFormInitially || (uiState.savedServers.isEmpty() && uiState.discoveredServers.isEmpty()), + ) } var editingServerId by rememberSaveable { mutableStateOf(null) } @@ -97,7 +107,7 @@ fun serverScreen( } is NavigationDestination.Projects -> { viewModel.clearNavigationDestination() - onNavigateToProjects() + onNavigateToSessions() } null -> { /* waiting for connection */ } } @@ -122,6 +132,7 @@ fun serverScreen( onConnectSavedServer = onConnectSavedServer, ), onSettings = onSettings, + onNavigateBack = onNavigateBack, ) } @@ -145,28 +156,30 @@ private data class ServerPresentation( private fun serverScaffold( presentation: ServerPresentation, onSettings: () -> Unit, + onNavigateBack: (() -> Unit)?, ) { val theme = LocalOpenCodeTheme.current Scaffold( topBar = { - TopAppBar( - title = { - Text( - "[ ${stringResource(R.string.server_connect_title)} ]", - fontFamily = FontFamily.Monospace, - color = theme.text, - ) - }, + TuiTopBar( + title = stringResource(R.string.server_connect_title), + onNavigateBack = onNavigateBack, actions = { - IconButton(onClick = onSettings, modifier = Modifier.testTag("server_settings_button")) { + // Sized to match the home header's settings button (48dp target, 20dp glyph). + IconButton( + onClick = onSettings, + modifier = Modifier + .size(Sizing.minTouchTarget) + .testTag("server_settings_button"), + ) { Icon( Icons.Default.Settings, contentDescription = stringResource(R.string.server_settings_cd), tint = theme.textMuted, + modifier = Modifier.size(Sizing.iconMd), ) } }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = theme.backgroundElement), ) }, containerColor = LocalOpenCodeTheme.current.background, @@ -238,6 +251,16 @@ private val serverContent: @Composable (Modifier, ServerPresentation) -> Unit = ) } } + // The editor already owns a URL/credentials form bound to the same uiState, so the + // add-a-server section stays hidden until the editor is dismissed. + if (presentation.editingServerId == null) { + manualServerSection( + presentation.uiState, + presentation.viewModel, + presentation.showManualForm, + presentation.onShowManualForm, + ) + } val showDiscovery = presentation.inventory.nearby.isNotEmpty() || presentation.uiState.discoveryState == DiscoveryState.SCANNING if (showDiscovery) { @@ -248,12 +271,6 @@ private val serverContent: @Composable (Modifier, ServerPresentation) -> Unit = presentation.viewModel::connectToDiscoveredServer, ) } - manualServerSection( - presentation.uiState, - presentation.viewModel, - presentation.showManualForm, - presentation.onShowManualForm, - ) serverFooter(presentation.uiState.error) } } @@ -283,13 +300,7 @@ private fun manualServerSection( } } else { remoteServerSection( - state = RemoteServerState( - uiState.remoteUrl, - uiState.username, - uiState.password, - uiState.allowInsecure, - uiState.isConnecting, - ), + state = uiState.toRemoteServerState(), actions = RemoteServerActions( viewModel::setRemoteUrl, viewModel::setUsername, @@ -333,6 +344,16 @@ private data class RemoteServerState( val password: String, val allowInsecure: Boolean, val isConnecting: Boolean, + val showTlsOptions: Boolean, +) + +private fun ServerUiState.toRemoteServerState() = RemoteServerState( + url = remoteUrl, + username = username, + password = password, + allowInsecure = allowInsecure, + isConnecting = isConnecting, + showTlsOptions = showTlsOptions, ) private data class RemoteServerActions( @@ -348,9 +369,9 @@ private fun remoteServerSection( state: RemoteServerState, actions: RemoteServerActions, ) { - val theme = LocalOpenCodeTheme.current var passwordVisible by remember { mutableStateOf(false) } - var showCredentials by rememberSaveable { mutableStateOf(false) } + // Credentials are required to reach an OpenCode server, so the panel starts open. + var showCredentials by rememberSaveable { mutableStateOf(true) } var urlFieldValue by remember { mutableStateOf(serverUrlTextFieldValue(state.url)) } LaunchedEffect(state.url) { @@ -359,46 +380,26 @@ private fun remoteServerSection( } } - Surface( - color = theme.backgroundElement, - shape = RectangleShape, - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Text( - text = "[ ${stringResource(R.string.server_remote_title)} ]", - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - ) - Text( - text = stringResource(R.string.server_remote_description), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - ) - remoteUrlField( - value = urlFieldValue, - onValueChange = { value -> - urlFieldValue = value - actions.onUrlChange(value.text) - }, - ) - credentialsSection( - state = state, - actions = actions, - controls = CredentialControls( - expanded = showCredentials, - passwordVisible = passwordVisible, - onToggleExpanded = { showCredentials = !showCredentials }, - onTogglePassword = { passwordVisible = !passwordVisible }, - ), - ) - cleartextCredentialWarning(state) - connectButton(state = state, onConnect = actions.onConnect) - } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.lg)) { + remoteUrlField( + value = urlFieldValue, + onValueChange = { value -> + urlFieldValue = value + actions.onUrlChange(value.text) + }, + ) + credentialsSection( + state = state, + actions = actions, + controls = CredentialControls( + expanded = showCredentials, + passwordVisible = passwordVisible, + onToggleExpanded = { showCredentials = !showCredentials }, + onTogglePassword = { passwordVisible = !passwordVisible }, + ), + ) + cleartextCredentialWarning(state) + connectButton(state = state, onConnect = actions.onConnect) } } @@ -422,24 +423,60 @@ private fun remoteUrlField( onValueChange: (TextFieldValue) -> Unit, ) { val theme = LocalOpenCodeTheme.current - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(stringResource(R.string.field_server_url), fontFamily = FontFamily.Monospace) }, - placeholder = { - Text( - stringResource(R.string.field_server_url_placeholder), + Column { + Text( + text = stringResource(R.string.setup_server_url_label), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, + ) + Spacer(Modifier.height(Spacing.sm)) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightLg) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.primary, RectangleShape) + .testTag("server_url_input"), + textStyle = TextStyle( + color = theme.text, fontFamily = FontFamily.Monospace, - ) - }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("server_url_input"), - shape = RectangleShape, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = theme.accent, - unfocusedBorderColor = theme.border, - ), - ) + fontSize = TuiCodeFontSize.xl, + ), + singleLine = true, + cursorBrush = SolidColor(theme.primary), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + decorationBox = { inner -> + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = Spacing.lg), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "/", + color = theme.primary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, + ) + Spacer(Modifier.width(Spacing.md)) + Box(Modifier.weight(1f)) { + if (value.text.isEmpty()) { + Text( + text = stringResource(R.string.field_server_url_placeholder), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + } + } + }, + ) + } } private data class CredentialControls( @@ -454,283 +491,264 @@ private fun credentialsSection( state: RemoteServerState, actions: RemoteServerActions, controls: CredentialControls, -) { - TextButton( - onClick = controls.onToggleExpanded, - modifier = Modifier.testTag("server_credentials_toggle"), - ) { - Icon( - if (controls.expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - ) - Text(stringResource(R.string.server_credentials), fontFamily = FontFamily.Monospace) - } - AnimatedVisibility(controls.expanded) { - credentialsFields(state, actions, controls.passwordVisible, controls.onTogglePassword) - } -} - -@Composable -private fun credentialsFields( - state: RemoteServerState, - actions: RemoteServerActions, - passwordVisible: Boolean, - onTogglePassword: () -> Unit, ) { val theme = LocalOpenCodeTheme.current - Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - OutlinedTextField( - value = state.username, - onValueChange = actions.onUsernameChange, - label = { Text(stringResource(R.string.field_username), fontFamily = FontFamily.Monospace) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("server_username_input"), - shape = RectangleShape, - ) - passwordField(state.password, actions.onPasswordChange, passwordVisible, onTogglePassword) + Column { Row( modifier = Modifier .fillMaxWidth() - .toggleable( - value = state.allowInsecure, - role = Role.Checkbox, - onValueChange = actions.onAllowInsecureChange, - ) - .padding(vertical = Spacing.xs) - .testTag("server_allow_insecure_toggle"), + .height(Sizing.buttonHeightLg) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.backgroundElement, RectangleShape) + .clickable(role = Role.Button) { controls.onToggleExpanded() } + .padding(horizontal = Spacing.lg) + .testTag("server_credentials_toggle"), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { - Checkbox( - checked = state.allowInsecure, - onCheckedChange = null, - colors = CheckboxDefaults.colors(checkedColor = theme.accent), + Text( + text = if (controls.expanded) "▾" else "▸", + color = theme.secondary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, ) - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.field_allow_insecure), - fontFamily = FontFamily.Monospace, - color = theme.text, - ) + Text( + text = stringResource(R.string.server_credentials), + color = theme.text, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + modifier = Modifier.weight(1f), + ) + if (!controls.expanded) { Text( - stringResource(R.string.field_allow_insecure_desc), + text = stringResource(R.string.setup_credentials_hint), + color = theme.border, fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall, + fontSize = TuiCodeFontSize.sm, ) } } + AnimatedVisibility(controls.expanded) { + credentialsFields(state, actions, controls.passwordVisible, controls.onTogglePassword) + } } } @Composable -private fun passwordField( - password: String, - onPasswordChange: (String) -> Unit, +private fun credentialsFields( + state: RemoteServerState, + actions: RemoteServerActions, passwordVisible: Boolean, onTogglePassword: () -> Unit, ) { - val theme = LocalOpenCodeTheme.current - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - label = { Text(stringResource(R.string.field_password), fontFamily = FontFamily.Monospace) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("server_password_input"), - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - shape = RectangleShape, - trailingIcon = { - IconButton( - onClick = onTogglePassword, - modifier = Modifier.testTag("server_password_visibility"), - ) { - Icon( - if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = stringResource(R.string.server_password_visibility_cd), - tint = theme.textMuted, - ) - } - }, - ) -} - -@Composable -private fun connectButton(state: RemoteServerState, onConnect: () -> Unit) { - val theme = LocalOpenCodeTheme.current - Button( - onClick = onConnect, - enabled = state.url.isNotBlank() && !state.isConnecting, - modifier = Modifier.fillMaxWidth().testTag("server_connect_button"), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors( - containerColor = theme.accent, - contentColor = theme.background, - ), - ) { - if (state.isConnecting) { - TuiLoadingIndicator() - Spacer(Modifier.width(Spacing.md)) - Text(stringResource(R.string.button_connecting), fontFamily = FontFamily.Monospace) - } else { - Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null) - Spacer(Modifier.width(Spacing.sm)) - Text(stringResource(R.string.button_connect), fontFamily = FontFamily.Monospace) + Column(Modifier.padding(top = Spacing.lg)) { + serverFieldLabel(stringResource(R.string.setup_username_label)) + Spacer(Modifier.height(Spacing.sm)) + serverFieldBox( + value = state.username, + onValueChange = actions.onUsernameChange, + options = ServerFieldOptions(testTag = "server_username_input"), + ) + Spacer(Modifier.height(Spacing.lg)) + serverFieldLabel(stringResource(R.string.setup_password_label)) + Spacer(Modifier.height(Spacing.sm)) + passwordField(state.password, actions.onPasswordChange, passwordVisible, onTogglePassword) + if (state.showTlsOptions) { + Spacer(Modifier.height(Spacing.xl)) + serverTlsSection(state.allowInsecure, actions.onAllowInsecureChange) } } } @Composable -private fun serverSetupHelpSection() { +private fun serverFieldLabel(label: String) { val theme = LocalOpenCodeTheme.current - var expanded by remember { mutableStateOf(false) } - - Surface( - color = theme.backgroundElement, - shape = RectangleShape, - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - // Header — always visible, acts as toggle - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(role = Role.Button) { expanded = !expanded }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "[ ? ${stringResource(R.string.server_setup_title)} ]", - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - ) - Text( - text = if (expanded) "▾" else "▸", - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - ) - } - - setupHelpContent(expanded) - } + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text( + text = label, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, + ) + Text( + text = stringResource(R.string.setup_field_required), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, + ) } } @Composable -private fun setupHelpContent(expanded: Boolean) { +private fun serverTlsSection(allowInsecure: Boolean, onAllowInsecureChange: (Boolean) -> Unit) { val theme = LocalOpenCodeTheme.current Text( - text = stringResource(R.string.server_setup_subtitle), - style = MaterialTheme.typography.bodySmall, + text = stringResource(R.string.setup_tls_label), fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.sm, color = theme.textMuted, + letterSpacing = 1.sp, ) - AnimatedVisibility(visible = expanded) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { - Spacer(Modifier.height(Spacing.xs)) - setupStep( - number = "1", - title = stringResource(R.string.server_setup_step1_title), - command = stringResource(R.string.server_setup_step1_cmd), - ) - setupStep( - number = "2", - title = stringResource(R.string.server_setup_step2_title), - command = stringResource(R.string.server_setup_step2_cmd), + Spacer(Modifier.height(Spacing.md)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Spacing.md) + .testTag("server_allow_insecure_toggle"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.lg), + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.field_allow_insecure), + color = theme.text, + fontSize = TuiCodeFontSize.xl, ) - setupStep( - number = "3", - title = stringResource(R.string.server_setup_step3_title), - command = stringResource(R.string.server_setup_step3_cmd), + Text( + text = stringResource(R.string.setup_tls_skip_verification), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, ) - setupHelpTip() } + TuiSwitch(checked = allowInsecure, onCheckedChange = onAllowInsecureChange) + } + Row( + modifier = Modifier.padding(top = Spacing.sm, bottom = Spacing.xl), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text("⚠", color = theme.warning, fontFamily = FontFamily.Monospace, fontSize = TuiCodeFontSize.md) + Text( + text = stringResource(R.string.setup_tls_warning), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + ) } } -private val setupHelpTip: @Composable () -> Unit = { +private data class ServerFieldOptions( + val testTag: String, + val keyboardType: KeyboardType = KeyboardType.Text, + val visualTransformation: VisualTransformation = VisualTransformation.None, +) + +@Composable +private fun serverFieldBox( + value: String, + onValueChange: (String) -> Unit, + options: ServerFieldOptions, + trailing: (@Composable () -> Unit)? = null, +) { val theme = LocalOpenCodeTheme.current - Surface( - color = theme.accent.copy(alpha = 0.08f), - shape = RectangleShape, - modifier = Modifier.border( - Sizing.strokeThin, - theme.accent.copy(alpha = 0.3f), - RectangleShape, + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(Sizing.textFieldHeightSm) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape) + .testTag(options.testTag), + textStyle = TextStyle( + color = theme.text, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, ), - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Text( - text = "── ${stringResource(R.string.server_setup_tip_label)} ──", - fontFamily = FontFamily.Monospace, - color = theme.accent, - style = MaterialTheme.typography.labelMedium, - ) - Text( - text = stringResource(R.string.server_setup_tip_text), - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall, - ) - Surface( - color = theme.background, - shape = RectangleShape, - modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape), + singleLine = true, + cursorBrush = SolidColor(theme.primary), + visualTransformation = options.visualTransformation, + keyboardOptions = KeyboardOptions(keyboardType = options.keyboardType), + decorationBox = { inner -> + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = Spacing.lg), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(R.string.server_setup_find_ip), - modifier = Modifier.fillMaxWidth().padding(Spacing.sm), - fontFamily = FontFamily.Monospace, - color = theme.accent, - style = MaterialTheme.typography.bodySmall, - ) + Box(Modifier.weight(1f)) { inner() } + if (trailing != null) { + Spacer(Modifier.width(Spacing.md)) + trailing() + } } + }, + ) +} + +@Composable +private fun passwordField( + password: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onTogglePassword: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + serverFieldBox( + value = password, + onValueChange = onPasswordChange, + options = ServerFieldOptions( + testTag = "server_password_input", + keyboardType = KeyboardType.Password, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + ), + trailing = { Text( - text = stringResource(R.string.server_setup_test_hint), + text = if (passwordVisible) { + stringResource(R.string.setup_password_hide) + } else { + stringResource(R.string.setup_password_show) + }, + color = theme.secondary, fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall, + fontSize = TuiCodeFontSize.lg, + modifier = Modifier + .clickable(role = Role.Button, onClick = onTogglePassword) + .testTag("server_password_visibility"), ) - } - } + }, + ) } @Composable -private fun setupStep( - number: String, - title: String, - command: String, -) { +private fun connectButton(state: RemoteServerState, onConnect: () -> Unit) { val theme = LocalOpenCodeTheme.current - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { - Text( - text = "$number. $title", - fontFamily = FontFamily.Monospace, - color = theme.text, - style = MaterialTheme.typography.bodyMedium, - ) - Surface( - color = theme.background, - shape = RectangleShape, - modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape), - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightLg) + .background(theme.primary, RectangleShape) + .clickable(enabled = !state.isConnecting, role = Role.Button) { onConnect() } + .testTag("server_connect_button"), + contentAlignment = Alignment.Center, + ) { + if (state.isConnecting) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + CircularProgressIndicator( + modifier = Modifier.size(Sizing.iconXs), + color = theme.background, + strokeWidth = Sizing.strokeThick, + ) + Text( + text = stringResource(R.string.button_connecting), + color = theme.background, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = TuiCodeFontSize.xxl, + ) + } + } else { Text( - text = command, - modifier = Modifier.fillMaxWidth().padding(Spacing.sm), - fontFamily = FontFamily.Monospace, - color = theme.accent, - style = MaterialTheme.typography.bodySmall, + text = stringResource(R.string.button_connect).uppercase(), + color = theme.background, + fontWeight = FontWeight.SemiBold, + fontSize = TuiCodeFontSize.xxl, + letterSpacing = 0.5.sp, ) } } @@ -755,35 +773,18 @@ private fun savedServersSection( state: SavedServersState, actions: SavedServerActions, ) { - val theme = LocalOpenCodeTheme.current var pendingForget by remember { mutableStateOf?>(null) } - Surface(color = theme.backgroundElement, shape = RectangleShape) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Text( - text = "[ ${stringResource(R.string.server_saved_servers)} ]", - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - ) - Text( - text = stringResource(R.string.server_saved_servers_desc), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - ) - state.servers.forEach { entry -> - key(entry.server.id) { - savedServerRow( - entry = entry, - isConnecting = state.isConnecting, - openTabCount = state.openTabsByEndpoint[entry.server.endpointKey].orEmpty().size, - actions = actions, - onForget = { server, count -> pendingForget = server to count }, - ) - } + Column { + TuiSectionHeader(text = stringResource(R.string.server_saved_servers)) + state.servers.forEach { entry -> + key(entry.server.id) { + savedServerRow( + entry = entry, + isConnecting = state.isConnecting, + openTabCount = state.openTabsByEndpoint[entry.server.endpointKey].orEmpty().size, + actions = actions, + onForget = { server, count -> pendingForget = server to count }, + ) } } } @@ -811,14 +812,19 @@ private fun savedServerRow( Row( modifier = Modifier .fillMaxWidth() + .background(theme.backgroundPanel, RectangleShape) .clickable(enabled = !isConnecting, role = Role.Button) { actions.onServerClick(server) } - .padding(vertical = Spacing.sm), + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.mdLg), ) { - Text("[${server.badgeLabel}]", fontFamily = FontFamily.Monospace, color = theme.textMuted) + Box( + modifier = Modifier + .size(Sizing.indicatorDotActive) + .background(serverStatusVisual(entry.status).color, RectangleShape), + ) savedServerDetails(entry, openTabCount, Modifier.weight(1f)) Box { IconButton( @@ -862,41 +868,46 @@ private fun savedServerDetails( val theme = LocalOpenCodeTheme.current val server = entry.server Column(modifier) { - Text(server.displayName, fontFamily = FontFamily.Monospace, color = theme.text, maxLines = 1) Text( - server.endpoint, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, + server.displayName, + color = theme.text, + fontSize = TuiCodeFontSize.xl, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - serverStatusIndicator(entry.status) Text( - if (server.username.isNullOrBlank()) { - stringResource(R.string.server_auth_default) - } else { - stringResource(R.string.server_auth_configured) - }, - style = MaterialTheme.typography.bodySmall, + server.endpoint, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + val metaParts = buildList { + add( + if (server.username.isNullOrBlank()) { + stringResource(R.string.server_auth_default) + } else { + stringResource(R.string.server_auth_configured) + }, + ) + add( + if (server.allowInsecure) { + stringResource(R.string.server_tls_checks_off) + } else { + stringResource(R.string.server_tls_checks_on) + }, + ) + if (openTabCount > 0) add(stringResource(R.string.server_open_tabs_count, openTabCount)) + } Text( - if (server.allowInsecure) { - stringResource(R.string.server_tls_checks_off) - } else { - stringResource(R.string.server_tls_checks_on) - }, - style = MaterialTheme.typography.bodySmall, + metaParts.joinToString(" · "), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, color = if (server.allowInsecure) theme.warning else theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - if (openTabCount > 0) { - Text( - stringResource(R.string.server_open_tabs_count, openTabCount), - style = MaterialTheme.typography.bodySmall, - color = theme.warning, - ) - } } } @@ -923,19 +934,18 @@ private fun savedServerEditorForm(presentation: SavedServerEditorPresentation, o val theme = LocalOpenCodeTheme.current Column(Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text("[ ${server.displayName} ]", fontFamily = FontFamily.Monospace, color = theme.text) + Text( + server.displayName, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = theme.text, + ) IconButton(onClick = presentation.onDismiss) { Icon(Icons.Default.Close, stringResource(R.string.server_close_details)) } } remoteServerSection( - RemoteServerState( - uiState.remoteUrl, - uiState.username, - uiState.password, - uiState.allowInsecure, - uiState.isConnecting, - ), + uiState.toRemoteServerState(), RemoteServerActions( viewModel::setRemoteUrl, viewModel::setUsername, @@ -1041,125 +1051,3 @@ private fun forgetServerDialog( } } -@Composable -private fun discoveredServersSection( - servers: List, - discoveryState: DiscoveryState, - isConnecting: Boolean, - onServerClick: (DiscoveredServer) -> Unit, -) { - val theme = LocalOpenCodeTheme.current - - Surface( - color = theme.backgroundElement, - shape = RectangleShape, - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "[ ${stringResource(R.string.discovery_section_title)} ]", - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - ) - if (discoveryState == DiscoveryState.SCANNING) { - scanningIndicator() - } - } - - if (servers.isEmpty() && discoveryState == DiscoveryState.SCANNING) { - Text( - text = stringResource(R.string.discovery_scanning_hint), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - ) - } - - servers.forEach { server -> - discoveredServerRow(server, isConnecting, onServerClick) - } - } - } -} - -@Composable -private fun discoveredServerRow( - server: DiscoveredServer, - isConnecting: Boolean, - onServerClick: (DiscoveredServer) -> Unit, -) { - val theme = LocalOpenCodeTheme.current - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, role = Role.Button) { - onServerClick(server) - } - .testTag("discovered_server_${server.serviceName}") - .padding(vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.lg), - ) { - serverStatusIndicator(ServerConnectionStatus.AVAILABLE) - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.serviceName, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "${server.host}:${server.port}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - Icons.Default.ChevronRight, - contentDescription = stringResource(R.string.button_connect), - tint = theme.textMuted, - ) - } -} - -private val scanningIndicator: @Composable () -> Unit = { - val theme = LocalOpenCodeTheme.current - val infiniteTransition = rememberInfiniteTransition(label = "scanning") - val alpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1.0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 800), - repeatMode = RepeatMode.Reverse, - ), - label = "scanPulse", - ) - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { - CircularProgressIndicator( - Modifier.size(Sizing.indicatorDotActive), - color = theme.accent.copy(alpha = alpha), - strokeWidth = Sizing.strokeMd, - ) - Text( - stringResource(R.string.discovery_scanning), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.accent.copy(alpha = alpha), - ) - } -} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerSetupHelp.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerSetupHelp.kt new file mode 100644 index 00000000..2caeba0b --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerSetupHelp.kt @@ -0,0 +1,250 @@ +package dev.blazelight.p4oc.ui.screens.server + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +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.font.FontWeight +import androidx.compose.ui.text.withStyle +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize + +/** + * "Server Setup" help (design 03): the three install/start/connect steps plus a + * networking tip. [serverSetupHelpSection] wraps it in the collapsible row used on + * the connect-to-server screen; the first-run screen embeds [serverSetupHelpContent] + * directly under its own footer toggle so both screens render the same help. + */ +@Composable +internal fun serverSetupHelpSection() { + val theme = LocalOpenCodeTheme.current + var expanded by rememberSaveable { mutableStateOf(false) } + + Column { + serverSetupHelpToggle(expanded, "server_setup_help_toggle") { expanded = !expanded } + AnimatedVisibility(expanded) { + Surface(color = theme.backgroundElement, shape = RectangleShape) { + serverSetupHelpContent(Modifier.padding(Spacing.md)) + } + } + } +} + +/** + * The collapsible header row — "Server Setup · Need help starting a server? ▸". + * Shared so the first-run footer and the connect screen present the same affordance. + */ +@Composable +internal fun serverSetupHelpToggle( + expanded: Boolean, + testTag: String, + onToggle: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightLg) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.backgroundElement, RectangleShape) + .clickable(role = Role.Button) { onToggle() } + .padding(horizontal = Spacing.lg) + .testTag(testTag), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text( + text = stringResource(R.string.server_setup_title), + color = theme.text, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = TuiCodeFontSize.lg, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.server_setup_subtitle), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + ) + Text( + text = if (expanded) "▾" else "▸", + color = theme.secondary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + ) + } +} + +/** The three numbered steps plus the networking tip, without any surrounding chrome. */ +@Composable +internal fun serverSetupHelpContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + setupStep( + number = "1", + title = stringResource(R.string.server_setup_step1_title), + command = stringResource(R.string.server_setup_step1_cmd), + ) + setupStep( + number = "2", + title = stringResource(R.string.server_setup_step2_title), + command = stringResource(R.string.server_setup_step2_cmd), + ) + setupStep( + number = "3", + title = stringResource(R.string.server_setup_step3_title), + command = stringResource(R.string.server_setup_step3_cmd), + ) + setupHelpTip() + } +} + +@Composable +private fun setupHelpTip() { + val theme = LocalOpenCodeTheme.current + Surface( + color = theme.primary.copy(alpha = TIP_FILL_ALPHA), + shape = RectangleShape, + modifier = Modifier.border( + Sizing.strokeThin, + theme.primary.copy(alpha = TIP_BORDER_ALPHA), + RectangleShape, + ), + ) { + Column( + modifier = Modifier.padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = "── ${stringResource(R.string.server_setup_tip_label)} ──", + fontFamily = FontFamily.Monospace, + color = theme.primary, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = emphasize( + text = stringResource(R.string.server_setup_tip_text), + emphasis = stringResource(R.string.server_setup_tip_flag), + emphasisColor = theme.primary, + ), + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + commandBlock(stringResource(R.string.server_setup_find_ip)) + Text( + text = stringResource(R.string.server_setup_test_label), + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = stringResource(R.string.server_setup_test_url), + fontFamily = FontFamily.Monospace, + color = theme.primary, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun setupStep( + number: String, + title: String, + command: String, +) { + val theme = LocalOpenCodeTheme.current + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text( + text = "$number. $title", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = theme.text, + style = MaterialTheme.typography.bodyMedium, + ) + commandBlock(command) + } +} + +/** Inset code box: command text in the accent colour, trailing `# ...` comments dimmed. */ +@Composable +private fun commandBlock(command: String) { + val theme = LocalOpenCodeTheme.current + Surface( + color = theme.background, + shape = RectangleShape, + modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape), + ) { + Text( + text = dimComments(command, theme.textMuted), + modifier = Modifier.fillMaxWidth().padding(Spacing.sm), + fontFamily = FontFamily.Monospace, + color = theme.primary, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +/** Recolours every occurrence of [emphasis] within [text] — used to pick out `--hostname 0.0.0.0`. */ +private fun emphasize(text: String, emphasis: String, emphasisColor: Color): AnnotatedString = + buildAnnotatedString { + var cursor = 0 + while (true) { + val match = text.indexOf(emphasis, cursor) + if (match < 0) break + append(text.substring(cursor, match)) + withStyle(SpanStyle(color = emphasisColor)) { append(emphasis) } + cursor = match + emphasis.length + } + append(text.substring(cursor)) + } + +/** Dims trailing `# ...` comments on each line so `$ ip addr # Linux` reads as command + note. */ +private fun dimComments(command: String, commentColor: Color): AnnotatedString = + buildAnnotatedString { + command.lineSequence().forEachIndexed { index, line -> + if (index > 0) append("\n") + val hash = line.indexOf('#') + if (hash < 0) { + append(line) + } else { + append(line.substring(0, hash)) + withStyle(SpanStyle(color = commentColor)) { append(line.substring(hash)) } + } + } + } + +private const val TIP_FILL_ALPHA = 0.08f +private const val TIP_BORDER_ALPHA = 0.3f diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index 268ea445..7390d1b5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -24,6 +24,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException enum class ServerConnectionStatus { CONNECTED, CONNECTING, AVAILABLE, DISCONNECTED, ERROR } @@ -185,9 +188,10 @@ class ServerViewModel constructor( val state = _uiState.value AppLog.d(TAG, "connectToRemote called") - if (state.remoteUrl.isBlank()) { - AppLog.w(TAG, "URL is blank, showing error") - _uiState.update { it.copy(error = "Please enter a server URL") } + val formError = state.connectFormError() + if (formError != null) { + AppLog.w(TAG, "Connect form incomplete") + _uiState.update { it.copy(error = formError) } return } @@ -260,11 +264,15 @@ class ServerViewModel constructor( connectingEndpointKey = null, connectedEndpointKey = endpointKey, failedEndpointKey = null, + showTlsOptions = false, ) } }, onFailure = { error -> AppLog.e(TAG, "Connection failed") + // A TLS trust failure is the only case where the self-signed escape hatch is + // useful, so it stays hidden until an attempt actually fails that way. + val tlsFailure = error.isTlsTrustFailure() // Clear password from UI state on failure too - user can re-enter _uiState.update { it.copy( @@ -272,7 +280,12 @@ class ServerViewModel constructor( connectingEndpointKey = null, failedEndpointKey = endpointKey, password = "", - error = "Could not connect to the server. Check the address, credentials, and connection." + showTlsOptions = it.showTlsOptions || tlsFailure, + error = if (tlsFailure) { + "The server's TLS certificate is not trusted." + } else { + "Could not connect. Check the address, credentials, and network." + } ) } } @@ -313,6 +326,7 @@ class ServerViewModel constructor( username = server.username ?: ServerUrl.DEFAULT_USERNAME, password = password, allowInsecure = server.allowInsecure, + showTlsOptions = server.allowInsecure, error = null, ) } @@ -384,13 +398,17 @@ class ServerViewModel constructor( } fun connectToDiscoveredServer(server: DiscoveredServer) { + // Reuse a stored password when we already know this endpoint; otherwise the form fills in + // and connectToRemote stops with "enter the server password" rather than a 401 round-trip. + val savedPassword = credentialStore.getServerPassword(server.url).orEmpty() _uiState.update { it.copy( remoteUrl = server.url, serverNameCandidate = server.serviceName, username = ServerUrl.DEFAULT_USERNAME, - password = "", - allowInsecure = server.allowInsecure + password = savedPassword, + allowInsecure = server.allowInsecure, + showTlsOptions = server.allowInsecure, ) } connectToRemote() @@ -410,12 +428,43 @@ class ServerViewModel constructor( } } +/** + * Why the connect form cannot be submitted yet, or `null` when it is complete. OpenCode serves + * behind basic auth, so a username and password are as required as the URL itself. + */ +internal fun ServerUiState.connectFormError(): String? = when { + remoteUrl.isBlank() -> "Enter a server URL" + username.isBlank() -> "Enter a username (usually opencode)" + password.isBlank() -> "Enter the server password" + else -> null +} + +/** True when [this] (or any cause beneath it) is a certificate/TLS trust failure. */ +internal fun Throwable.isTlsTrustFailure(): Boolean { + var current: Throwable? = this + val seen = HashSet() + while (current != null && seen.add(current)) { + val isTls = current is SSLException || + current is CertificateException || + current is CertPathValidatorException + if (isTls) return true + current = current.cause + } + return false +} + data class ServerUiState( val remoteUrl: String = "", val serverNameCandidate: String? = null, - val username: String = "opencode", + val username: String = ServerUrl.DEFAULT_USERNAME, val password: String = "", val allowInsecure: Boolean = false, + /** + * Whether to surface the self-signed-certificate escape hatch. OpenCode is normally served + * over plain HTTP, so the toggle only appears once a connection actually fails TLS validation + * (or when editing a server that already has it on). + */ + val showTlsOptions: Boolean = false, val isConnecting: Boolean = false, val isConnected: Boolean = false, val error: String? = null, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt index aa74cbc9..d89885bb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.settings +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -259,61 +260,74 @@ private fun AgentCard( onClick: () -> Unit ) { val theme = LocalOpenCodeTheme.current - Card( + val agentColor = getAgentColor(agent.name) + Surface( modifier = Modifier.fillMaxWidth(), - onClick = onClick + onClick = onClick, + shape = RectangleShape, + color = theme.backgroundElement, ) { Row( modifier = Modifier .fillMaxWidth() .padding(Spacing.md), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + verticalAlignment = Alignment.Top ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(Spacing.lg), - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - shape = RectangleShape, - color = getAgentColor(agent.name).copy(alpha = 0.2f) + // Square status dot in the agent's color (design 14) + Box( + modifier = Modifier + .padding(top = Spacing.xs) + .size(Sizing.indicatorDotActive) + .background(agentColor, RectangleShape) + ) + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - getAgentIcon(agent.name), - contentDescription = stringResource(R.string.cd_agent_icon), - modifier = Modifier.padding(Spacing.md), - tint = getAgentColor(agent.name) - ) - } - - Column { Text( - text = agent.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = agent.description, - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted, - maxLines = 2 + text = "@${agent.name}", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ), + fontWeight = FontWeight.SemiBold, + color = theme.text ) + if (agent.isBuiltIn) { + Surface(shape = RectangleShape, color = agentColor) { + Text( + text = stringResource(R.string.agents_builtin), + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ), + color = theme.background, + modifier = Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xxs) + ) + } + } + } + Text( + text = agent.description, + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + maxLines = 2, + modifier = Modifier.padding(top = Spacing.xxs) + ) - if (agent.tools.isNotEmpty()) { - Spacer(Modifier.height(Spacing.xs)) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { - agent.tools.take(3).forEach { tool -> - AgentToolLabel(tool) - } - if (agent.tools.size > 3) { - Text( - text = "+${agent.tools.size - 3}", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - modifier = Modifier.align(Alignment.CenterVertically) - ) - } + if (agent.tools.isNotEmpty()) { + Spacer(Modifier.height(Spacing.xs)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + agent.tools.take(3).forEach { tool -> + AgentToolLabel(tool) + } + if (agent.tools.size > 3) { + Text( + text = "+${agent.tools.size - 3}", + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + modifier = Modifier.align(Alignment.CenterVertically) + ) } } } @@ -373,14 +387,15 @@ private fun AgentDetailDialog( style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium ) - Card( - colors = CardDefaults.cardColors( - containerColor = theme.backgroundElement - ) + Surface( + shape = RectangleShape, + color = theme.backgroundElement ) { Text( text = prompt.take(500) + if (prompt.length > 500) "..." else "", - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ), modifier = Modifier.padding(Spacing.lg) ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt index 0fbba51b..443feb10 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt @@ -104,13 +104,7 @@ fun ConnectionSettingsScreen( @Composable private fun SectionHeader(title: String) { - val theme = LocalOpenCodeTheme.current - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = theme.accent, - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md) - ) + dev.blazelight.p4oc.ui.components.TuiSectionHeader(title) } @Composable diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt index 4053c953..7cccd54d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt @@ -51,6 +51,8 @@ data class ModelInfo( val id: String, val name: String, val providerId: String, + /** The provider's own display name; falls back to [providerId] when the server omits one. */ + val providerName: String = providerId, val contextLength: Int = 0, val inputCostPer1k: Double = 0.0, val outputCostPer1k: Double = 0.0, @@ -104,6 +106,7 @@ class ModelControlsViewModel constructor( id = dto.id, name = dto.name, providerId = dto.providerId, + providerName = provider.name.takeIf { it.isNotBlank() } ?: dto.providerId, contextLength = dto.limit?.context ?: dto.contextLength ?: 0, inputCostPer1k = dto.cost?.input ?: dto.inputCostPer1k ?: 0.0, outputCostPer1k = dto.cost?.output ?: dto.outputCostPer1k ?: 0.0, @@ -193,7 +196,8 @@ internal enum class ModelListContentState { MODELS, EMPTY, NO_RESULTS } internal fun filteredModels(state: ModelControlsState): List = state.models.filter { model -> val matchesSearch = state.searchQuery.isBlank() || model.name.contains(state.searchQuery, ignoreCase = true) || - model.id.contains(state.searchQuery, ignoreCase = true) + model.id.contains(state.searchQuery, ignoreCase = true) || + model.providerName.contains(state.searchQuery, ignoreCase = true) val matchesProvider = state.filterProvider == null || model.providerId == state.filterProvider matchesSearch && matchesProvider }.sortedByDescending { it.isFavorite } @@ -221,7 +225,7 @@ fun ModelControlsScreen( } val providers = remember(state.models) { - state.models.map { it.providerId }.distinct() + state.models.map { it.providerId to it.providerName }.distinct().sortedBy { it.second.lowercase() } } val theme = LocalOpenCodeTheme.current @@ -408,7 +412,7 @@ private fun SearchBar( @Composable private fun ProviderFilterChips( - providers: List, + providers: List>, selected: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier @@ -423,11 +427,11 @@ private fun ProviderFilterChips( label = { Text(stringResource(R.string.all)) }, shape = RectangleShape ) - providers.forEach { provider -> + providers.forEach { (providerId, label) -> FilterChip( - selected = selected == provider, - onClick = { onSelect(if (selected == provider) null else provider) }, - label = { Text(provider) }, + selected = selected == providerId, + onClick = { onSelect(if (selected == providerId) null else providerId) }, + label = { Text(label) }, shape = RectangleShape ) } @@ -459,6 +463,7 @@ internal fun ModelCard( .semantics { if (isSelected) stateDescription = currentModelDescription }, + shape = RectangleShape, colors = CardDefaults.cardColors( containerColor = if (isSelected) { theme.accent.copy(alpha = 0.2f) @@ -488,7 +493,7 @@ internal fun ModelCard( fontWeight = FontWeight.Medium ) Text( - text = model.providerId, + text = model.providerName, style = MaterialTheme.typography.bodySmall, color = theme.textMuted ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt index b1e703ac..ec1f9135 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt @@ -29,9 +29,15 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.R +import androidx.compose.foundation.BorderStroke +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import dev.blazelight.p4oc.core.datastore.NotificationRoutingMode import dev.blazelight.p4oc.core.datastore.NotificationSettings import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VibrationPattern +import dev.blazelight.p4oc.ui.components.TuiDropdownMenuItem import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.ui.components.TuiAlertDialog import dev.blazelight.p4oc.ui.components.TuiButton @@ -55,12 +61,27 @@ class NotificationSettingsViewModel constructor( private val _settings = MutableStateFlow(NotificationSettings()) val settings: StateFlow = _settings.asStateFlow() + private val _savedServers = MutableStateFlow>(emptyList()) + val savedServers: StateFlow> = _savedServers.asStateFlow() + init { viewModelScope.launch { settingsDataStore.notificationSettings.collect { saved -> _settings.value = saved } } + viewModelScope.launch { + settingsDataStore.savedServers.collect { servers -> + _savedServers.value = servers + } + } + } + + fun setServerRouting(endpointKey: String, mode: dev.blazelight.p4oc.core.datastore.NotificationRoutingMode) { + val updatedRouting = _settings.value.serverRouting.toMutableMap().apply { put(endpointKey, mode) } + val new = _settings.value.copy(serverRouting = updatedRouting) + _settings.value = new + viewModelScope.launch { settingsDataStore.updateNotificationSettings(new) } } fun setEnabled(enabled: Boolean) { @@ -105,6 +126,7 @@ fun NotificationSettingsScreen( onNavigateBack: () -> Unit ) { val settings by viewModel.settings.collectAsStateWithLifecycle() + val savedServers by viewModel.savedServers.collectAsStateWithLifecycle() val theme = LocalOpenCodeTheme.current val context = LocalContext.current @@ -227,6 +249,28 @@ fun NotificationSettingsScreen( testTag = "notify_on_completion_switch" ) + // Per-server routing (design 18) + SectionHeader(title = stringResource(R.string.notification_routing_section)) + if (savedServers.isEmpty()) { + Text( + text = stringResource(R.string.notification_routing_empty), + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md) + ) + } else { + savedServers.forEach { server -> + ServerRoutingRow( + name = server.displayName, + endpoint = server.endpointKey, + mode = settings.serverRouting[server.endpointKey] + ?: NotificationRoutingMode.All, + enabled = settings.enabled, + onSelect = { viewModel.setServerRouting(server.endpointKey, it) }, + ) + } + } + VibrationPatternRow( pattern = settings.vibrationPattern, onClick = { showVibrationPatternDialog = true } @@ -326,13 +370,90 @@ private fun PermissionWarningBanner( @Composable private fun SectionHeader(title: String) { + dev.blazelight.p4oc.ui.components.TuiSectionHeader(title) +} + +@Composable +private fun routingModeColor(mode: NotificationRoutingMode): Color { val theme = LocalOpenCodeTheme.current - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = theme.accent, - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md) - ) + return when (mode) { + NotificationRoutingMode.All -> theme.success + NotificationRoutingMode.Mentions -> theme.warning + NotificationRoutingMode.Off -> theme.textMuted + } +} + +@Composable +private fun routingModeLabel(mode: NotificationRoutingMode): String = stringResource( + when (mode) { + NotificationRoutingMode.All -> R.string.notification_routing_all + NotificationRoutingMode.Mentions -> R.string.notification_routing_mentions + NotificationRoutingMode.Off -> R.string.notification_routing_off + } +) + +@Composable +private fun ServerRoutingRow( + name: String, + endpoint: String, + mode: NotificationRoutingMode, + enabled: Boolean, + onSelect: (NotificationRoutingMode) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + var expanded by remember { mutableStateOf(false) } + val contentAlpha = if (enabled) 1f else 0.4f + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.text.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = endpoint, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box { + Surface( + onClick = { if (enabled) expanded = true }, + shape = RectangleShape, + color = Color.Transparent, + border = BorderStroke(Sizing.strokeMd, theme.border), + ) { + Text( + text = "${routingModeLabel(mode)} ▾", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = routingModeColor(mode).copy(alpha = contentAlpha), + modifier = Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xxs), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + NotificationRoutingMode.entries.forEach { option -> + TuiDropdownMenuItem( + text = routingModeLabel(option), + onClick = { + onSelect(option) + expanded = false + }, + ) + } + } + } + } + HorizontalDivider(thickness = Sizing.dividerThickness, color = theme.borderSubtle) } @Composable diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt index 79b23347..f28c6ff3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt @@ -443,10 +443,18 @@ private fun ModelItem( ) { model.capabilities?.let { caps -> if (caps.reasoning) { - CapabilityChip("Reasoning") + Text( + text = "[R]", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.info, + ) } if (caps.toolcall) { - CapabilityChip("Tools") + Text( + text = "[T]", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.accent, + ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt index 6848e110..f3350c0d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import dev.blazelight.p4oc.BuildConfig import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.ui.components.TuiConfirmDialog +import dev.blazelight.p4oc.ui.components.TuiSectionHeader import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.components.status.SessionStatusDot import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme @@ -79,66 +80,75 @@ fun SettingsScreen( .padding(padding) .verticalScroll(rememberScrollState()) ) { - // Server info (non-clickable) - SettingsItem( - icon = if (uiState.isLocal) Icons.Default.PhoneAndroid else Icons.Default.Cloud, - title = stringResource(R.string.server), - subtitle = uiState.serverUrl - ) + // Server-scoped configuration only appears when Settings was opened from a workspace + // tab; opened globally there is no single server these could apply to. + val scopedServerName = viewModel.scopedServerName + if (scopedServerName != null) { + // Server info (non-clickable) + SettingsItem( + icon = if (uiState.isLocal) Icons.Default.PhoneAndroid else Icons.Default.Cloud, + title = scopedServerName, + subtitle = uiState.serverUrl + ) - SettingsItem( - icon = Icons.Default.SmartToy, - title = stringResource(R.string.settings_providers), - subtitle = if (isConnected && onProviderConfig != null) { - stringResource(R.string.settings_providers_desc) - } else { - stringResource(R.string.settings_requires_connection) - }, - onClick = onProviderConfig?.takeIf { isConnected }, - showChevron = isConnected && onProviderConfig != null, - enabled = isConnected && onProviderConfig != null, - testTag = "settings_provider_item" - ) + TuiSectionHeader(stringResource(R.string.settings_group_server, scopedServerName)) - SettingsItem( - icon = Icons.Default.Tune, - title = stringResource(R.string.settings_model_controls), - subtitle = if (isConnected && onModelControls != null) { - stringResource(R.string.settings_model_controls_desc) - } else { - stringResource(R.string.settings_requires_connection) - }, - onClick = onModelControls?.takeIf { isConnected }, - showChevron = isConnected && onModelControls != null, - enabled = isConnected && onModelControls != null, - testTag = "settings_model_controls_item" - ) + SettingsItem( + icon = Icons.Default.SmartToy, + title = stringResource(R.string.settings_providers), + subtitle = if (isConnected && onProviderConfig != null) { + stringResource(R.string.settings_providers_desc) + } else { + stringResource(R.string.settings_requires_connection) + }, + onClick = onProviderConfig?.takeIf { isConnected }, + showChevron = isConnected && onProviderConfig != null, + enabled = isConnected && onProviderConfig != null, + testTag = "settings_provider_item" + ) - SettingsItem( - icon = Icons.Default.Groups, - title = stringResource(R.string.settings_agents), - subtitle = if (isConnected) { - stringResource(R.string.settings_agents_desc) - } else { - stringResource(R.string.settings_requires_connection) - }, - onClick = if (isConnected) onAgentsConfig else null, - showChevron = isConnected, - enabled = isConnected - ) + SettingsItem( + icon = Icons.Default.Tune, + title = stringResource(R.string.settings_model_controls), + subtitle = if (isConnected && onModelControls != null) { + stringResource(R.string.settings_model_controls_desc) + } else { + stringResource(R.string.settings_requires_connection) + }, + onClick = onModelControls?.takeIf { isConnected }, + showChevron = isConnected && onModelControls != null, + enabled = isConnected && onModelControls != null, + testTag = "settings_model_controls_item" + ) - SettingsItem( - icon = Icons.Default.Extension, - title = stringResource(R.string.settings_skills), - subtitle = if (isConnected) { - stringResource(R.string.settings_skills_desc) - } else { - stringResource(R.string.settings_requires_connection) - }, - onClick = if (isConnected) onSkills else null, - showChevron = isConnected, - enabled = isConnected - ) + SettingsItem( + icon = Icons.Default.Groups, + title = stringResource(R.string.settings_agents), + subtitle = if (isConnected) { + stringResource(R.string.settings_agents_desc) + } else { + stringResource(R.string.settings_requires_connection) + }, + onClick = if (isConnected) onAgentsConfig else null, + showChevron = isConnected, + enabled = isConnected + ) + + SettingsItem( + icon = Icons.Default.Extension, + title = stringResource(R.string.settings_skills), + subtitle = if (isConnected) { + stringResource(R.string.settings_skills_desc) + } else { + stringResource(R.string.settings_requires_connection) + }, + onClick = if (isConnected) onSkills else null, + showChevron = isConnected, + enabled = isConnected + ) + } + + TuiSectionHeader(stringResource(R.string.settings_group_app)) // These don't require connection SettingsItem( @@ -216,6 +226,18 @@ fun SettingsScreen( testTag = "settings_disconnect_button" ) } + + // Version footer (design 13) + Text( + text = "P4OC v${BuildConfig.VERSION_NAME} · ${BuildConfig.APPLICATION_ID}", + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ), + color = theme.border, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = Spacing.md) + ) } } @@ -499,9 +521,9 @@ private fun SettingsItem( } if (showChevron) { Text( - text = "→", - style = MaterialTheme.typography.bodyMedium, - color = theme.textMuted.copy(alpha = contentAlpha) + text = "›", + style = MaterialTheme.typography.titleMedium, + color = theme.border.copy(alpha = contentAlpha) ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt index 07697081..970948b0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt @@ -31,6 +31,17 @@ class SettingsViewModel constructor( settingsDataStore.connectionSettings .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ConnectionSettings()) + /** + * Display name of the server these settings are scoped to, or `null` when Settings was opened + * outside any workspace tab. Provider/agent/skill configuration lives on one specific server, + * so with several servers registered there is no meaningful "current" one to target — the + * caller hides that group instead of showing it permanently disabled. + */ + val scopedServerName: String? = when (val context = connectionContext) { + SettingsConnectionContext.Global -> null + is SettingsConnectionContext.Tab -> context.owner.workspace.server.displayName + } + /** Whether the app is currently connected to an OpenCode server. */ val isConnected: StateFlow = connectionContext.connectionState(serverConnectionRegistry) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt index 68ce8f73..2499b427 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt @@ -271,7 +271,9 @@ private fun SkillCard( val theme = LocalOpenCodeTheme.current Card( modifier = Modifier.fillMaxWidth(), - onClick = onClick + onClick = onClick, + shape = RectangleShape, + colors = CardDefaults.cardColors(containerColor = theme.backgroundElement) ) { Row( modifier = Modifier diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt index 23a9bf92..2c57c20b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt @@ -282,6 +282,7 @@ private fun SettingsSection( modifier = Modifier .fillMaxWidth() .padding(horizontal = Spacing.md), + shape = RectangleShape, colors = CardDefaults.cardColors( containerColor = theme.backgroundElement ) @@ -331,20 +332,11 @@ private fun ThemeModeSelector( "dark" to "Dark" ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.md) - ) { - modes.forEach { (id, label) -> - FilterChip( - selected = selected == id, - onClick = { onSelect(id) }, - label = { Text(label) }, - modifier = Modifier.weight(1f), - shape = RectangleShape - ) - } - } + dev.blazelight.p4oc.ui.components.TuiSegmentedControl( + options = modes, + selectedId = selected, + onSelect = onSelect, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -436,6 +428,7 @@ private fun PreviewCard(settings: VisualSettings) { modifier = Modifier .fillMaxWidth() .padding(Spacing.md), + shape = RectangleShape, colors = CardDefaults.cardColors( containerColor = theme.backgroundElement ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/setup/SetupScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/setup/SetupScreen.kt index 5b5e8521..cfe398e0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/setup/SetupScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/setup/SetupScreen.kt @@ -1,97 +1,591 @@ package dev.blazelight.p4oc.ui.screens.setup +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.core.network.DiscoveredServer +import dev.blazelight.p4oc.core.network.DiscoveryState +import dev.blazelight.p4oc.ui.components.TuiSwitch +import dev.blazelight.p4oc.ui.screens.server.ServerUiState +import dev.blazelight.p4oc.ui.screens.server.ServerViewModel +import dev.blazelight.p4oc.ui.screens.server.serverSetupHelpContent +import dev.blazelight.p4oc.ui.screens.server.serverSetupHelpToggle import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize +import org.koin.androidx.compose.koinViewModel +private const val SCAN_PULSE_MIN_ALPHA = 0.35f +private const val SCAN_PULSE_DURATION_MS = 700 + +/** + * First-run setup (design 01) — the P4OC hero plus an inline connect form: + * server URL, collapsible credentials/security, CONNECT, nearby-server scan, + * and a "Server Setup" help footer. Backed by [ServerViewModel]; navigates to + * [onConnected] once a connection succeeds. + */ @Composable fun SetupScreen( - onSetupComplete: () -> Unit + onConnected: () -> Unit ) { val theme = LocalOpenCodeTheme.current + val viewModel: ServerViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.start(autoReconnect = true) } + + DisposableEffect(Unit) { + viewModel.startDiscovery() + onDispose { viewModel.stopDiscovery() } + } + + LaunchedEffect(uiState.navigationDestination) { + if (uiState.navigationDestination != null) { + viewModel.clearNavigationDestination() + onConnected() + } + } - Scaffold( - containerColor = theme.background - ) { padding -> + Scaffold(containerColor = theme.background) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(Spacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .imePadding(), ) { - WelcomeStep(onNext = onSetupComplete) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.xl), + verticalArrangement = Arrangement.Center, + ) { + setupHero() + Spacer(Modifier.height(Spacing.xl + Spacing.sm)) + serverUrlField(uiState.remoteUrl, viewModel::setRemoteUrl) + Spacer(Modifier.height(Spacing.lg)) + credentialsPanel(uiState, viewModel) + Spacer(Modifier.height(Spacing.lg)) + connectButton(uiState, viewModel::connectToRemote) + setupError(uiState.error) + Spacer(Modifier.height(Spacing.xl)) + discoverySection(uiState, viewModel) + } + setupFooter() } } } @Composable -private fun WelcomeStep(onNext: () -> Unit) { +private fun setupHero() { + val theme = LocalOpenCodeTheme.current + Text( + text = ">_", + style = MaterialTheme.typography.displayLarge, + color = theme.primary, + fontFamily = FontFamily.Monospace, + ) + Spacer(Modifier.height(Spacing.xl)) + Text( + text = stringResource(R.string.setup_brand_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = theme.text, + ) + Spacer(Modifier.height(Spacing.xs)) + Text( + text = stringResource(R.string.setup_brand_tagline), + style = MaterialTheme.typography.bodyMedium, + color = theme.textMuted, + ) + Spacer(Modifier.height(Spacing.xs)) + Text( + text = stringResource(R.string.server_connect_hint), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + color = theme.secondary, + ) +} + +@Composable +private fun serverUrlField(value: String, onValueChange: (String) -> Unit) { + val theme = LocalOpenCodeTheme.current + Text( + text = stringResource(R.string.setup_server_url_label), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, + ) + Spacer(Modifier.height(Spacing.sm)) + setupInputBox( + value = value, + onValueChange = onValueChange, + options = SetupFieldOptions( + placeholder = stringResource(R.string.field_server_url_placeholder), + keyboardType = KeyboardType.Uri, + testTag = "setup_url_input", + ), + leading = { + Text( + text = "/", + color = theme.primary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, + ) + }, + ) +} + +@Composable +private fun credentialsPanel(uiState: ServerUiState, viewModel: ServerViewModel) { val theme = LocalOpenCodeTheme.current + // Credentials are required to reach an OpenCode server, so the panel starts open. + var expanded by rememberSaveable { mutableStateOf(true) } + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightLg) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.backgroundElement, RectangleShape) + .clickable(role = Role.Button) { expanded = !expanded } + .padding(horizontal = Spacing.lg) + .testTag("setup_credentials_toggle"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text( + text = if (expanded) "▾" else "▸", + color = theme.secondary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + ) + Text( + text = stringResource(R.string.server_credentials), + color = theme.text, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + modifier = Modifier.weight(1f), + ) + if (!expanded) { + Text( + text = stringResource(R.string.setup_credentials_hint), + color = theme.border, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.sm, + ) + } + } + AnimatedVisibility(expanded) { + credentialsFields(uiState, viewModel) + } + } +} - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md) +@Composable +private fun credentialsFields(uiState: ServerUiState, viewModel: ServerViewModel) { + val theme = LocalOpenCodeTheme.current + var passwordVisible by remember { mutableStateOf(false) } + Column(Modifier.padding(top = Spacing.lg)) { + fieldLabel(stringResource(R.string.setup_username_label)) + Spacer(Modifier.height(Spacing.sm)) + setupInputBox( + value = uiState.username, + onValueChange = viewModel::setUsername, + options = SetupFieldOptions( + height = Sizing.textFieldHeightSm, + testTag = "setup_username_input", + ), + ) + Spacer(Modifier.height(Spacing.lg)) + + fieldLabel(stringResource(R.string.setup_password_label)) + Spacer(Modifier.height(Spacing.sm)) + setupInputBox( + value = uiState.password, + onValueChange = viewModel::setPassword, + options = SetupFieldOptions( + height = Sizing.textFieldHeightSm, + keyboardType = KeyboardType.Password, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + testTag = "setup_password_input", + ), + trailing = { + Text( + text = if (passwordVisible) { + stringResource(R.string.setup_password_hide) + } else { + stringResource(R.string.setup_password_show) + }, + color = theme.secondary, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + modifier = Modifier + .clickable(role = Role.Button) { passwordVisible = !passwordVisible } + .testTag("setup_password_visibility"), + ) + }, + ) + if (uiState.showTlsOptions) { + Spacer(Modifier.height(Spacing.xl)) + credentialsTlsSection(uiState.allowInsecure, viewModel::setAllowInsecure) + } else { + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@Composable +private fun credentialsTlsSection(allowInsecure: Boolean, onAllowInsecureChange: (Boolean) -> Unit) { + val theme = LocalOpenCodeTheme.current + Text( + text = stringResource(R.string.setup_tls_label), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.sm, + color = theme.textMuted, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(Spacing.md)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.lg), + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.field_allow_insecure), + color = theme.text, + fontSize = TuiCodeFontSize.xl, + ) + Text( + text = stringResource(R.string.setup_tls_skip_verification), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + ) + } + TuiSwitch( + checked = allowInsecure, + onCheckedChange = onAllowInsecureChange, + modifier = Modifier.testTag("setup_allow_insecure_toggle"), + ) + } + Row( + modifier = Modifier.padding(top = Spacing.sm, bottom = Spacing.xl), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { Text( - text = "◇", - style = MaterialTheme.typography.displayLarge, - color = theme.accent, - fontFamily = FontFamily.Monospace + text = "⚠", + color = theme.warning, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, ) - Text( - text = stringResource(R.string.setup_welcome_title), - style = MaterialTheme.typography.headlineMedium, + text = stringResource(R.string.setup_tls_warning), + color = theme.textMuted, fontFamily = FontFamily.Monospace, - color = theme.text, - textAlign = TextAlign.Center + fontSize = TuiCodeFontSize.md, ) + } +} +@Composable +private fun fieldLabel(label: String) { + val theme = LocalOpenCodeTheme.current + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { Text( - text = stringResource(R.string.setup_welcome_subtitle), - style = MaterialTheme.typography.bodyLarge, + text = label, fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - color = theme.textMuted + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, ) + Text( + text = stringResource(R.string.setup_field_required), + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + color = theme.textMuted, + ) + } +} - Spacer(Modifier.height(Spacing.lg)) +@Composable +private fun connectButton(uiState: ServerUiState, onConnect: () -> Unit) { + val theme = LocalOpenCodeTheme.current + Box( + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightLg) + .background(theme.primary, RectangleShape) + .clickable(enabled = !uiState.isConnecting, role = Role.Button) { onConnect() } + .testTag("setup_connect_button"), + contentAlignment = Alignment.Center, + ) { + if (uiState.isConnecting) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + CircularProgressIndicator( + modifier = Modifier.size(Sizing.iconXs), + color = theme.background, + strokeWidth = Sizing.strokeThick, + ) + Text( + text = stringResource(R.string.button_connecting), + color = theme.background, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = TuiCodeFontSize.xxl, + ) + } + } else { + Text( + text = stringResource(R.string.button_connect).uppercase(), + color = theme.background, + fontWeight = FontWeight.SemiBold, + fontSize = TuiCodeFontSize.xxl, + letterSpacing = 0.5.sp, + ) + } + } +} +@Composable +private fun setupError(error: String?) { + val theme = LocalOpenCodeTheme.current + if (error == null) return + Spacer(Modifier.height(Spacing.sm)) + Text( + text = error, + color = theme.error, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + ) +} + +@Composable +private fun discoverySection(uiState: ServerUiState, viewModel: ServerViewModel) { + val scanning = uiState.discoveryState == DiscoveryState.SCANNING + if (scanning) { + scanningRow() + Spacer(Modifier.height(Spacing.md)) + } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + uiState.discoveredServers.forEach { server -> + discoveredRow(server, uiState.isConnecting) { + viewModel.connectToDiscoveredServer(server) + } + } + } +} + +@Composable +private fun scanningRow() { + val theme = LocalOpenCodeTheme.current + val transition = rememberInfiniteTransition(label = "scanning") + val alpha by transition.animateFloat( + initialValue = SCAN_PULSE_MIN_ALPHA, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(SCAN_PULSE_DURATION_MS), RepeatMode.Reverse), + label = "scanPulse", + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + CircularProgressIndicator( + modifier = Modifier.size(Sizing.iconXxs), + color = theme.warning.copy(alpha = alpha), + strokeWidth = Sizing.strokeThick, + ) Text( - text = stringResource(R.string.server_remote_description), - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.setup_scanning_nearby), + color = theme.warning, fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - color = theme.textMuted + fontSize = TuiCodeFontSize.md, ) + } +} - Spacer(Modifier.height(Spacing.lg)) - - Button( - onClick = onNext, - modifier = Modifier.fillMaxWidth(), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors( - containerColor = theme.accent, - contentColor = theme.background +@Composable +private fun discoveredRow( + server: DiscoveredServer, + isConnecting: Boolean, + onClick: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .background(theme.backgroundPanel, RectangleShape) + .clickable(enabled = !isConnecting, role = Role.Button, onClick = onClick) + .padding(horizontal = Spacing.lg, vertical = Spacing.md) + .testTag("setup_discovered_${server.serviceName}"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.mdLg), + ) { + Box( + modifier = Modifier + .size(Sizing.indicatorDotActive) + .background(theme.info, RectangleShape), + ) + Column(Modifier.weight(1f)) { + Text( + text = server.serviceName, + color = theme.text, + fontSize = TuiCodeFontSize.xl, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - ) { Text( - "→ ${stringResource(R.string.setup_get_started)}", - fontFamily = FontFamily.Monospace + text = "${server.host}:${server.port}", + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.md, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } + Text("›", color = theme.textMuted, fontSize = TuiCodeFontSize.xxl) } } + +/** + * Bottom-anchored "Server Setup" help. The toggle row stays pinned and the shared + * [serverSetupHelpContent] expands upward above it, so the first-run screen shows the + * same steps and networking tip as the connect-to-server screen. + */ +@Composable +private fun setupFooter() { + val theme = LocalOpenCodeTheme.current + var expanded by rememberSaveable { mutableStateOf(false) } + Column(modifier = Modifier.padding(horizontal = Spacing.xl, vertical = Spacing.lg)) { + AnimatedVisibility(expanded) { + serverSetupHelpContent( + Modifier + .padding(bottom = Spacing.md) + .background(theme.backgroundElement, RectangleShape) + .border(Sizing.strokeMd, theme.backgroundPanel, RectangleShape) + .verticalScroll(rememberScrollState()) + .heightIn(max = Sizing.embeddedScrollMaxHeight) + .padding(Spacing.md), + ) + } + serverSetupHelpToggle( + expanded = expanded, + testTag = "setup_server_help_toggle", + ) { expanded = !expanded } + } +} + +private data class SetupFieldOptions( + val placeholder: String = "", + val height: Dp = Sizing.buttonHeightLg, + val keyboardType: KeyboardType = KeyboardType.Text, + val visualTransformation: VisualTransformation = VisualTransformation.None, + val testTag: String = "", +) + +@Composable +private fun setupInputBox( + value: String, + onValueChange: (String) -> Unit, + options: SetupFieldOptions = SetupFieldOptions(), + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val theme = LocalOpenCodeTheme.current + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(options.height) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape) + .then(if (options.testTag.isNotEmpty()) Modifier.testTag(options.testTag) else Modifier), + textStyle = TextStyle( + color = theme.text, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, + ), + singleLine = true, + cursorBrush = SolidColor(theme.primary), + visualTransformation = options.visualTransformation, + keyboardOptions = KeyboardOptions(keyboardType = options.keyboardType), + decorationBox = { inner -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Spacing.lg), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + leading() + Spacer(Modifier.width(Spacing.md)) + } + Box(Modifier.weight(1f)) { + if (value.isEmpty() && options.placeholder.isNotEmpty()) { + Text( + text = options.placeholder, + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.xl, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + } + if (trailing != null) { + Spacer(Modifier.width(Spacing.md)) + trailing() + } + } + }, + ) +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index b461ea2b..6c70e3b2 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -5,6 +5,7 @@ package dev.blazelight.p4oc.ui.tabs +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -17,14 +18,16 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets 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.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -42,7 +45,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -57,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalLifecycleOwner @@ -67,7 +70,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -103,9 +109,9 @@ import dev.blazelight.p4oc.ui.screens.home.HomeSummaryInput import dev.blazelight.p4oc.ui.screens.home.ScopedHomeRepositoryState import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.ProjectColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing -import dev.blazelight.p4oc.ui.theme.TuiShapes import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow @@ -113,6 +119,7 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject private const val TAG = "MainTabScreen" +private const val SELECTED_ROW_TINT = 0.12f private data class SavedServerView( val endpointKey: String, @@ -136,8 +143,13 @@ private class StartWorkUiState { var showStartWorkPicker: Boolean by mutableStateOf(false) var homeDetailSelection: StartWorkSelection by mutableStateOf(StartWorkSelection.NeedsSelection) var pendingStartWork: Pair? by mutableStateOf(null) - var pickerSelectedEndpointKey: String? by mutableStateOf(null) var pickerSearchQuery: String by mutableStateOf("") + + /** Servers the user has explicitly expanded/collapsed in the picker. */ + val pickerExpandedServers: SnapshotStateMap = mutableStateMapOf() + + /** Servers where the user tapped "show more" past the first page of workspaces. */ + val pickerShowAllServers: SnapshotStateMap = mutableStateMapOf() } internal enum class PendingStartDisposition { @@ -167,26 +179,35 @@ private val startWorkPickerSearch: @Composable (StartWorkUiState) -> Unit = { ui value = uiState.pickerSearchQuery, onValueChange = { uiState.pickerSearchQuery = it }, singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = theme.text), - cursorBrush = SolidColor(theme.accent), + textStyle = MaterialTheme.typography.labelMedium.copy( + color = theme.text, + fontFamily = FontFamily.Monospace, + ), + cursorBrush = SolidColor(theme.primary), modifier = Modifier .fillMaxWidth() - .border(Sizing.strokeThin, theme.border, RectangleShape) + .height(Sizing.textFieldHeightSm) + .background(theme.backgroundPanel, RectangleShape) + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape) .semantics { contentDescription = searchDescription } .testTag("start_work_search_field"), decorationBox = { field -> Row( - Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), + Modifier.fillMaxSize().padding(horizontal = Spacing.sm), verticalAlignment = Alignment.CenterVertically, ) { - Text("/", style = MaterialTheme.typography.labelMedium, color = theme.textMuted) + Text( + "/", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.primary, + ) Spacer(Modifier.width(Spacing.xs)) Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { field() if (uiState.pickerSearchQuery.isEmpty()) { Text( stringResource(R.string.start_work_filter_workspaces), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), color = theme.textMuted, maxLines = 1, ) @@ -197,97 +218,215 @@ private val startWorkPickerSearch: @Composable (StartWorkUiState) -> Unit = { ui ) } -private val startWorkServerRail: @Composable ( - List, - StartWorkPickerGroup?, - StartWorkUiState, -) -> Unit = { groups, selectedGroup, uiState -> +/** + * Collapsible `▸ server ─ N workspaces` header. Collapsing keeps a server with a long workspace + * list from burying the others, and the count stays visible while collapsed. + */ +@Composable +private fun startWorkServerHeader( + row: StartWorkPickerRow, + connectionState: ConnectionState?, + onToggle: () -> Unit, +) { val theme = LocalOpenCodeTheme.current val resources = LocalResources.current - LazyRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { - items(groups, key = { it.server.endpointKey }) { group -> - val selected = group.server.endpointKey == selectedGroup?.server?.endpointKey - Surface( - color = theme.backgroundPanel, - shape = RectangleShape, - modifier = Modifier - .width(Sizing.serverFilterCardWidth) - .then(if (selected) Modifier.border(Sizing.strokeMd, theme.primary) else Modifier) - .clickable(role = Role.Tab) { - uiState.pickerSelectedEndpointKey = group.server.endpointKey - uiState.pickerSearchQuery = "" - } - .semantics { - contentDescription = resources.getString( - R.string.start_work_server_workspaces, - group.server.displayName, - group.targets.size - 1, - ) - this.selected = selected - } - .testTag("start_work_server_${group.server.endpointKey}"), + val server = row.group.server + val workspaceCount = row.matchCount + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Sizing.minTouchTarget) + .background(theme.backgroundPanel, RectangleShape) + .clickable(role = Role.Button, onClick = onToggle) + .padding(horizontal = Spacing.sm, vertical = Spacing.xs) + .semantics { + contentDescription = resources.getString( + R.string.start_work_server_workspaces, + server.displayName, + workspaceCount, + ) + selected = row.expanded + } + .testTag("start_work_server_${server.endpointKey}"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = if (row.expanded) "▾" else "▸", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.secondary, + ) + Box( + Modifier + .size(Sizing.indicatorDot) + .background(connectionStatusColor(connectionState), RectangleShape), + ) + Text( + server.displayName, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + fontWeight = FontWeight.SemiBold, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = pluralWorkspaceCount(workspaceCount), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + ) + } +} + +@Composable +private fun pluralWorkspaceCount(count: Int): String = if (count == 1) { + stringResource(R.string.start_work_workspace_count_one) +} else { + stringResource(R.string.start_work_workspace_count_other, count) +} + +/** `+ N more` row that lifts the per-server cap once the list runs past a screenful. */ +@Composable +private fun startWorkShowMoreRow(endpointKey: String, hiddenCount: Int, onShowAll: () -> Unit) { + val theme = LocalOpenCodeTheme.current + Text( + text = stringResource(R.string.start_work_show_more, hiddenCount), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.accent, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Sizing.minTouchTarget) + .clickable(role = Role.Button, onClick = onShowAll) + .padding(horizontal = Spacing.md, vertical = Spacing.sm) + .testTag("start_work_show_more_$endpointKey"), + ) +} + +private fun LazyListScope.startWorkPickerLedger( + params: MainTabContentParams, + rows: List, +) { + val uiState = params.uiState + rows.forEach { row -> + val endpointKey = row.group.server.endpointKey + item(key = "server:$endpointKey") { + startWorkServerHeader( + row = row, + connectionState = params.scopedConnectionStates[endpointKey], ) { - Column( - Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs), - ) { - Text( - group.badgeLabel, - style = MaterialTheme.typography.labelMedium, - color = if (selected) theme.primary else theme.text, - ) - Text( - group.server.displayName, - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + uiState.pickerExpandedServers[endpointKey] = !row.expanded + } + } + items( + items = row.visibleTargets, + key = { target -> "target:$endpointKey:${target.workspaceKey}" }, + ) { target -> + startWorkPickerTargetRow(params, target) + } + if (row.hiddenCount > 0) { + item(key = "more:$endpointKey") { + startWorkShowMoreRow(endpointKey, row.hiddenCount) { + uiState.pickerShowAllServers[endpointKey] = true } } } } + if (rows.isEmpty()) { + item(key = "picker_no_matches") { + Text( + stringResource(R.string.start_work_no_matching_workspaces), + style = MaterialTheme.typography.labelMedium, + color = LocalOpenCodeTheme.current.textMuted, + modifier = Modifier.padding(vertical = Spacing.md), + ) + } + } + item(key = "picker_navigation_bar") { Spacer(Modifier.navigationBarsPadding()) } } -private val startWorkPickerLedger: @Composable ColumnScope.( - MainTabContentParams, - List, -) -> Unit = { params, targets -> +@Composable +private fun startWorkPickerTargetRow(params: MainTabContentParams, target: StartWorkTarget) { val labels = rememberTabTitleLabels() - targets.firstOrNull { it.workspaceKey == WorkspaceKey.Global }?.let { globalTarget -> - filesWorkspaceOption( - title = stringResource(R.string.sessions_global), - subtitle = workspaceSubtitle(globalTarget.workspaceKey), - marker = "◆", - onClick = { selectStartWorkPickerTarget(params, globalTarget) }, - modifier = Modifier.testTag("start_work_target_global"), - ) - } - val directories = targets.filter { it.workspaceKey != WorkspaceKey.Global } - if (directories.isEmpty()) { - Text( - stringResource(R.string.start_work_no_matching_workspaces), - style = MaterialTheme.typography.labelMedium, - color = LocalOpenCodeTheme.current.textMuted, - modifier = Modifier.padding(vertical = Spacing.md), - ) - } else { - LazyColumn( - Modifier.fillMaxWidth().weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(Spacing.hairline), + val currentTarget = params.uiState.startWorkContext?.selectedTarget + val isGlobal = target.workspaceKey == WorkspaceKey.Global + startWorkWorkspaceRow( + spec = StartWorkWorkspaceRowSpec( + target = target, + title = if (isGlobal) { + stringResource(R.string.sessions_global) + } else { + workspaceLabel(target.workspaceKey, labels) ?: workspaceSubtitle(target.workspaceKey) + }, + subtitle = workspaceSubtitle(target.workspaceKey), + selected = target == currentTarget, + ), + onClick = { selectStartWorkPickerTarget(params, target) }, + modifier = Modifier.testTag( + if (isGlobal) "start_work_target_global" else "start_work_target_${target.workspaceKey}", + ), + ) +} + +private data class StartWorkWorkspaceRowSpec( + val target: StartWorkTarget, + val title: String, + val subtitle: String, + val selected: Boolean, +) + +@Composable +private fun startWorkWorkspaceRow( + spec: StartWorkWorkspaceRowSpec, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val theme = LocalOpenCodeTheme.current + val title = spec.title + val subtitle = spec.subtitle + val selected = spec.selected + val projectColor = + ProjectColors.colorForProject("${spec.target.serverRef.endpointKey}:${spec.target.workspaceKey}") + Surface( + onClick = onClick, + shape = RectangleShape, + color = if (selected) projectColor.copy(alpha = SELECTED_ROW_TINT) else theme.backgroundElement, + modifier = modifier + .fillMaxWidth() + .heightIn(min = Sizing.minTouchTarget), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - items( - items = directories, - key = { target -> "target:${target.serverRef.endpointKey}:${target.workspaceKey}" }, - ) { target -> - filesWorkspaceOption( - title = workspaceLabel(target.workspaceKey, labels) ?: workspaceSubtitle(target.workspaceKey), - subtitle = workspaceSubtitle(target.workspaceKey), - marker = "◇", - onClick = { selectStartWorkPickerTarget(params, target) }, - modifier = Modifier.testTag("start_work_target_${target.workspaceKey}"), + Text("◆", style = MaterialTheme.typography.bodyMedium, color = projectColor) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) projectColor else theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - item(key = "picker_navigation_bar") { Spacer(Modifier.navigationBarsPadding()) } + Text( + text = "→", + style = MaterialTheme.typography.bodyMedium, + color = if (selected) projectColor else theme.textMuted, + ) } } } @@ -342,6 +481,8 @@ private data class MainTabContentParams( val savedServerExists: (String) -> Boolean, val connectSavedServer: (String) -> Unit, val onDisconnect: () -> Unit, + val onSettings: () -> Unit, + val onRefreshHome: () -> Unit, ) @Composable @@ -806,6 +947,7 @@ object MainTabScreen { pendingNotificationRoute: StateFlow, onNotificationRouteConsumed: (NotificationRoute) -> Unit, onDisconnect: () -> Unit, + onSettings: () -> Unit = {}, modifier: Modifier = Modifier, ) { val deps = rememberMainTabDeps() @@ -865,12 +1007,17 @@ object MainTabScreen { ) mainTabPresenceCollection(tabs, activeTabId, tabMaps) - val homeRepositoryStates = tabMaps.workspaceOwners.values + val distinctWorkspaceOwners = tabMaps.workspaceOwners.values .distinctBy { it.workspace.server.endpointKey to it.workspace.key } - .map { owner -> - val state by owner.sessionRepository.state.collectAsStateWithLifecycle() - ScopedHomeRepositoryState(owner.workspace.server, state) + val homeRepositoryStates = distinctWorkspaceOwners.map { owner -> + val state by owner.sessionRepository.state.collectAsStateWithLifecycle() + ScopedHomeRepositoryState(owner.workspace.server, state) + } + val onRefreshHome: () -> Unit = { + distinctWorkspaceOwners.forEach { owner -> + deps.coroutineScope.launch { owner.sessionRepository.refresh() } } + } val closeTab = rememberCloseTab(deps, tabMaps) val snackbarHostState = remember { SnackbarHostState() } @@ -898,6 +1045,8 @@ object MainTabScreen { savedServerExists = savedServerExists, connectSavedServer = connectSavedServer, onDisconnect = onDisconnect, + onSettings = onSettings, + onRefreshHome = onRefreshHome, ) mainTabScaffold(params, snackbarHostState, modifier) startWorkSheets(params) @@ -950,6 +1099,8 @@ private fun mainTabScaffold( onTabClose = params.closeTab, onAddClick = { if (params.deps.tabManager.activeTab?.isPinnedHome == true) { + params.uiState.startWorkContext = startWorkContextFor(params.deps.tabManager.activeTab) + .copy(defaultAction = StartWorkAction.NewChat) params.uiState.showStartWorkPicker = true } else { params.uiState.startWorkContext = startWorkContextFor(params.deps.tabManager.activeTab) @@ -1028,6 +1179,8 @@ private fun mainTabHomeContent( }, onBrowseAllSessions = { params.uiState.showFilesTabPrompt = true }, onManageServers = params.onDisconnect, + onRefresh = params.onRefreshHome, + onSettings = params.onSettings, onFocusTab = params.deps.tabManager::focusTab, onResumeSession = { session -> val existing = params.deps.tabManager.findTabBySessionId(session.sessionId.value) @@ -1200,18 +1353,23 @@ private fun startWorkSheetContent( .padding(bottom = Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - Text(stringResource(R.string.start_work_title), style = MaterialTheme.typography.titleLarge) + Text( + stringResource(R.string.start_work_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + ) if (target == null) { Text(stringResource(R.string.start_work_choose_context), color = theme.textMuted) LaunchedEffect(Unit) { params.uiState.showStartWorkPicker = true } } else { startWorkSheetTargetCard(params, target) + startWorkSectionHeader(stringResource(R.string.start_work_section_new)) startWorkSheetScopedActions(params, target) - Text(stringResource(R.string.start_work_existing_work), color = theme.textMuted) + startWorkSectionHeader(stringResource(R.string.start_work_section_existing)) startWorkActionRow( label = stringResource(R.string.start_work_sessions), description = stringResource(R.string.start_work_sessions_description), marker = "S", + markerColor = theme.accent, ) { params.uiState.showStartWorkSheet = false requestScopedAction(params, target, StartWorkAction.BrowseSessions) @@ -1220,44 +1378,97 @@ private fun startWorkSheetContent( } } +/** Uppercase, letter-spaced `NEW` / `EXISTING` section label for the Start-work sheet. */ @Composable +private fun startWorkSectionHeader(label: String) { + val theme = LocalOpenCodeTheme.current + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + letterSpacing = 1.sp, + color = theme.textMuted, + modifier = Modifier.padding(top = Spacing.sm, bottom = Spacing.xxs), + ) +} + +@Composable +@Suppress("LongMethod") private fun startWorkSheetTargetCard( params: MainTabContentParams, target: StartWorkTarget, ) { val theme = LocalOpenCodeTheme.current val tabTitleLabels = rememberTabTitleLabels() + val connectionState = params.scopedConnectionStates[target.serverRef.endpointKey] + val statusColor = connectionStatusColor(connectionState) + val workspaceName = workspaceLabel(target.workspaceKey, tabTitleLabels) + ?: workspaceSubtitle(target.workspaceKey) Surface( color = theme.backgroundElement, - shape = TuiShapes.small, - modifier = Modifier.fillMaxWidth().testTag("start_work_context"), + shape = RectangleShape, + modifier = Modifier + .fillMaxWidth() + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape) + .testTag("start_work_context"), ) { - Column(Modifier.padding(Spacing.md)) { + Column(Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.start_work_in), color = theme.textMuted) + Box( + modifier = Modifier + .size(Sizing.indicatorDotActive) + .background(statusColor, RectangleShape), + ) Spacer(Modifier.width(Spacing.sm)) - val connectionStatus = connectionStatusText( - params.scopedConnectionStates[target.serverRef.endpointKey], + Text( + target.serverRef.displayName, + color = statusColor, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + Spacer(Modifier.width(Spacing.sm)) Text( - "${target.serverRef.displayName} · $connectionStatus", + "· ${connectionStatusText(connectionState)}", color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) - TextButton(onClick = { params.uiState.showStartWorkPicker = true }) { - Text(stringResource(R.string.start_work_change)) - } + Text( + text = stringResource(R.string.start_work_change).lowercase(), + color = theme.accent, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .clickable(role = Role.Button) { params.uiState.showStartWorkPicker = true } + .padding(Spacing.xxs), + ) } Text( - workspaceLabel(target.workspaceKey, tabTitleLabels) - ?: workspaceSubtitle(target.workspaceKey), + text = workspaceName, + color = ProjectColors.colorForProject( + "${target.serverRef.endpointKey}:${target.workspaceKey}", + ), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleSmall, ) } } } +private val connectionStatusColor: @Composable (ConnectionState?) -> Color = { state -> + val theme = LocalOpenCodeTheme.current + when (state) { + is ConnectionState.Connected -> theme.success + is ConnectionState.Connecting -> theme.warning + is ConnectionState.Error -> theme.error + else -> theme.textMuted + } +} + private val connectionStatusText: @Composable (ConnectionState?) -> String = { state -> when (state) { is ConnectionState.Connected -> stringResource(R.string.server_status_connected) @@ -1272,17 +1483,21 @@ private fun startWorkSheetScopedActions( params: MainTabContentParams, target: StartWorkTarget, ) { + val theme = LocalOpenCodeTheme.current val labels = mapOf( - StartWorkAction.NewChat to (R.string.start_work_new_chat to "C"), - StartWorkAction.Files to (R.string.start_work_files to "F"), - StartWorkAction.Terminal to (R.string.start_work_terminal to "T"), + StartWorkAction.NewChat to Triple(R.string.start_work_new_chat, "C", theme.primary), + StartWorkAction.Files to Triple(R.string.start_work_files, "F", theme.info), + StartWorkAction.Terminal to Triple(R.string.start_work_terminal, "T", theme.success), ) startWorkScopedActionOrder.forEach { action -> - val (label, marker) = checkNotNull(labels[action]) + val (label, marker, markerColor) = checkNotNull(labels[action]) + // The target card directly above already names the server and workspace, so the rows + // stay single-line instead of repeating it three times. startWorkActionRow( label = stringResource(label), - description = stringResource(R.string.start_work_scoped_action), + description = null, marker = marker, + markerColor = markerColor, ) { params.uiState.showStartWorkSheet = false requestScopedAction(params, target, action) @@ -1303,11 +1518,16 @@ private fun startWorkPickerSheet(params: MainTabContentParams) { val knownHomeTargets = remember(params.homeRepositoryStates) { deriveStartWorkPickerTargets(params.homeRepositoryStates) } + // Every opening starts from the full tree rather than the previous session's filter. + LaunchedEffect(Unit) { uiState.pickerSearchQuery = "" } ModalBottomSheet( onDismissRequest = { uiState.showStartWorkPicker = false uiState.showFilesTabPrompt = false }, + // Opening fully expanded gives the workspace list a bounded height to scroll inside; + // a partially-expanded sheet clips the tail of a long server instead. + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = theme.background, modifier = Modifier.testTag("start_work_context_picker"), ) { @@ -1322,7 +1542,6 @@ private fun startWorkPickerContent( knownHomeTargets: List, ) { val theme = LocalOpenCodeTheme.current - val tabTitleLabels = rememberTabTitleLabels() val uiState = params.uiState val groups = remember(params.savedServerViews, openTargets, knownHomeTargets) { buildStartWorkPickerGroups( @@ -1331,25 +1550,41 @@ private fun startWorkPickerContent( knownHomeTargets, ) } - LaunchedEffect(groups, uiState.pickerSelectedEndpointKey) { - if (groups.none { it.server.endpointKey == uiState.pickerSelectedEndpointKey }) { - uiState.pickerSelectedEndpointKey = groups.firstOrNull()?.server?.endpointKey + val viewState = StartWorkPickerViewState( + query = uiState.pickerSearchQuery, + expandedOverrides = uiState.pickerExpandedServers, + showAllEndpointKeys = uiState.pickerShowAllServers.filterValues { it }.keys, + defaultExpandedEndpointKey = uiState.startWorkContext?.selectedTarget?.serverRef?.endpointKey + ?: groups.firstOrNull()?.server?.endpointKey, + ) + val rows = remember(groups, viewState) { buildStartWorkPickerRows(groups, viewState) } + Column( + Modifier.fillMaxSize().padding(horizontal = Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + stringResource(R.string.start_work_picker_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = theme.text, + ) + Text( + stringResource(R.string.start_work_picker_subtitle), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = theme.textMuted, + ) } - } - val pickerState = StartWorkPickerState(uiState.pickerSelectedEndpointKey, uiState.pickerSearchQuery) - val selectedGroup = groups.firstOrNull { it.server.endpointKey == pickerState.selectedEndpointKey } - val targets = remember(groups, pickerState) { pickerState.filteredTargets(groups) } - Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.md)) { - Text( - stringResource(R.string.start_work_choose_context), - style = MaterialTheme.typography.titleMedium, - ) if (params.savedServerViews.isEmpty()) { Text(stringResource(R.string.start_work_no_servers), color = theme.textMuted) } startWorkPickerSearch(uiState) - startWorkServerRail(groups, selectedGroup, uiState) - startWorkPickerLedger(params, targets) + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacing.hairline), + ) { + startWorkPickerLedger(params, rows) + } } } @@ -1370,7 +1605,11 @@ private val selectStartWorkPickerTarget: (MainTabContentParams, StartWorkTarget) params.uiState.homeDetailSelection = StartWorkSelection.Selected(pickedTarget) params.uiState.showStartWorkPicker = false params.uiState.showFilesTabPrompt = false - params.uiState.showStartWorkSheet = true + if (params.uiState.startWorkContext?.defaultAction == StartWorkAction.NewChat) { + requestScopedAction(params, pickedTarget, StartWorkAction.NewChat) + } else { + params.uiState.showStartWorkSheet = true + } } private val terminalTitle: (WorkspaceKey) -> String? = { workspaceKey -> @@ -1392,15 +1631,17 @@ private val workspaceSubtitle: (WorkspaceKey) -> String = { workspaceKey -> @Composable private fun startWorkActionRow( label: String, - description: String, + description: String?, marker: String, + markerColor: Color, onClick: () -> Unit, ) { - val actionDescription = stringResource(R.string.start_work_action_accessibility, label, description) + val actionDescription = stringResource(R.string.start_work_action_accessibility, label, description.orEmpty()) filesWorkspaceOption( title = label, subtitle = description, marker = marker, + markerColor = markerColor, onClick = onClick, modifier = Modifier .testTag("start_work_${marker.lowercase()}") @@ -1409,45 +1650,52 @@ private fun startWorkActionRow( } @Composable +@Suppress("LongParameterList") private fun filesWorkspaceOption( title: String, - subtitle: String, + subtitle: String?, marker: String, + markerColor: Color, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val theme = LocalOpenCodeTheme.current - Surface( + Row( modifier = modifier .fillMaxWidth() .heightIn(min = Sizing.minTouchTarget) - .clickable(role = Role.Button, onClick = onClick), - color = theme.backgroundElement, - shape = TuiShapes.small, + .clickable(role = Role.Button, onClick = onClick) + .padding(vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, ) { - Row( + // Bordered square badge with the action's letter (C/F/T/S). + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - verticalAlignment = Alignment.CenterVertically, + .size(Sizing.iconButtonSm) + .border(Sizing.strokeMd, theme.borderSubtle, RectangleShape), + contentAlignment = Alignment.Center, ) { Text( text = marker, style = MaterialTheme.typography.bodyMedium, - color = theme.textMuted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = markerColor, ) - Spacer(Modifier.width(Spacing.sm)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + } + Spacer(Modifier.width(Spacing.md)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { Text( text = subtitle, style = MaterialTheme.typography.labelSmall, @@ -1456,12 +1704,12 @@ private fun filesWorkspaceOption( overflow = TextOverflow.Ellipsis, ) } - Spacer(Modifier.width(Spacing.sm)) - Text( - text = "→", - style = MaterialTheme.typography.bodyMedium, - color = theme.textMuted, - ) } + Spacer(Modifier.width(Spacing.sm)) + Text( + text = "→", + style = MaterialTheme.typography.bodyMedium, + color = theme.textMuted, + ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt index 0e93ef8e..df1dc7ec 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt @@ -127,19 +127,69 @@ internal fun deriveStartWorkPickerTargets( } }.distinct() -internal data class StartWorkPickerState( - val selectedEndpointKey: String?, +/** Workspaces listed per server before the "show more" row takes over. */ +internal const val PICKER_WORKSPACE_PAGE_SIZE = 10 + +/** Workspaces of one server that match [query]; an empty query keeps every entry. */ +internal fun StartWorkPickerGroup.matchingTargets(query: String): List { + val needle = query.trim() + if (needle.isEmpty()) return targets + return targets.filter { target -> + target.workspaceKey.pickerSearchText().contains(needle, ignoreCase = true) + } +} + +/** One server section of the picker: its header state plus the workspace rows to draw under it. */ +internal data class StartWorkPickerRow( + val group: StartWorkPickerGroup, + val expanded: Boolean, + val matchCount: Int, + val visibleTargets: List, + val hiddenCount: Int, +) + +internal data class StartWorkPickerViewState( val query: String = "", + /** Explicit expand/collapse the user has toggled, keyed by endpoint. */ + val expandedOverrides: Map = emptyMap(), + /** Endpoints where the user asked to see past [PICKER_WORKSPACE_PAGE_SIZE] workspaces. */ + val showAllEndpointKeys: Set = emptySet(), + /** Server expanded by default — normally the one the picker was opened from. */ + val defaultExpandedEndpointKey: String? = null, ) -internal fun StartWorkPickerState.filteredTargets( +/** + * Folds [groups] into the picker's server sections. While searching, non-matching servers drop out + * and matching ones open regardless of collapse state, so results are never hidden behind a header. + */ +internal fun buildStartWorkPickerRows( groups: List, -): List { - val group = groups.firstOrNull { it.server.endpointKey == selectedEndpointKey } ?: return emptyList() - val needle = query.trim() - return group.targets.filter { target -> - target.workspaceKey == WorkspaceKey.Global || needle.isEmpty() || - target.workspaceKey.pickerSearchText().contains(needle, ignoreCase = true) + state: StartWorkPickerViewState, +): List { + val searching = state.query.isNotBlank() + val onlyServer = groups.size == 1 + return groups.mapNotNull { group -> + val endpointKey = group.server.endpointKey + val matches = group.matchingTargets(state.query) + if (searching && matches.isEmpty()) return@mapNotNull null + val expanded = when { + searching -> true + else -> state.expandedOverrides[endpointKey] + ?: (onlyServer || endpointKey == state.defaultExpandedEndpointKey) + } + val showAll = searching || endpointKey in state.showAllEndpointKeys + val visible = when { + !expanded -> emptyList() + showAll -> matches + else -> matches.take(PICKER_WORKSPACE_PAGE_SIZE) + } + StartWorkPickerRow( + group = group, + expanded = expanded, + matchCount = matches.size, + visibleTargets = visible, + hiddenCount = if (expanded) matches.size - visible.size else 0, + ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index 54f30195..aff4d886 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.tabs +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow @@ -13,7 +14,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -60,6 +65,7 @@ fun TabBar( ) { val theme = LocalOpenCodeTheme.current val listState = rememberLazyListState() + val newWorkLabel = stringResource(R.string.cd_new_work) // Home is pinned outside the scrolling work-tab list. LaunchedEffect(activeTabId, tabs) { @@ -71,14 +77,14 @@ fun TabBar( Surface( modifier = modifier.fillMaxWidth().testTag("tab_bar"), - color = theme.background, + color = theme.backgroundPanel, tonalElevation = Spacing.none ) { + Column { Row( modifier = Modifier .fillMaxWidth() - .height(Sizing.minTouchTarget) - .padding(horizontal = Spacing.xs), + .height(Sizing.tabBarHeight), verticalAlignment = Alignment.CenterVertically ) { tabs.firstOrNull { it.isPinnedHome }?.let { home -> @@ -145,22 +151,31 @@ fun TabBar( } } - // Add button - IconButton( - onClick = onAddClick, + // Add button — mono glyph with a left divider (design) + VerticalDivider( + modifier = Modifier.height(Sizing.tabBarHeight), + color = theme.border, + thickness = Sizing.strokeThin, + ) + Box( modifier = Modifier - .minimumInteractiveComponentSize() - .size(Sizing.iconLg) - .testTag("tab_bar_add_button") + .fillMaxHeight() + .clickable(onClick = onAddClick, role = Role.Button) + .padding(horizontal = Spacing.lg) + .semantics { contentDescription = newWorkLabel } + .testTag("tab_bar_add_button"), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_new_work), - modifier = Modifier.size(Sizing.iconSm), - tint = theme.textMuted + Text( + text = "+", + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = theme.textMuted ) } } + HorizontalDivider(color = theme.border, thickness = Sizing.strokeThin) + } } } @@ -177,22 +192,34 @@ private fun tabIndicator( val backgroundColor = when { needsAttention && !state.isActive -> theme.warning.copy(alpha = 0.15f) state.isActive -> theme.backgroundElement - else -> theme.background + else -> Color.Transparent } - Box( + // Active tab gets a 2px primary top-border strip — the design's key tab signature. + val topStripColor = if (state.isActive) theme.primary else Color.Transparent + Column( modifier = modifier - .minimumInteractiveComponentSize() - .height(Sizing.tabHeight) + .height(Sizing.tabBarHeight) + .background(backgroundColor) + .drawBehind { + drawRect( + color = topStripColor, + size = androidx.compose.ui.geometry.Size( + width = size.width, + height = Sizing.strokeThick.toPx(), + ), + ) + } .semantics { contentDescription = state.accessibilityLabel selected = state.isActive } .clickable(onClick = state.onClick, role = Role.Tab), - contentAlignment = Alignment.Center, ) { - Surface( - modifier = Modifier.height(Sizing.tabHeight), - color = backgroundColor, + Box( + modifier = Modifier + .weight(1f) + .padding(top = Sizing.strokeThick), + contentAlignment = Alignment.Center, ) { tabIndicatorRow(state = state, needsAttention = needsAttention) } @@ -202,15 +229,16 @@ private fun tabIndicator( @Composable private fun tabIndicatorRow(state: TabIndicatorState, needsAttention: Boolean) { val theme = LocalOpenCodeTheme.current + val closeLabel = stringResource(R.string.cd_close_tab) Row( - modifier = Modifier.padding(horizontal = Spacing.xs), + modifier = Modifier.padding(horizontal = Spacing.md), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.xxs), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), ) { tabIndicatorIcon(state) Text( text = state.title, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), color = when { needsAttention -> theme.warning state.isActive -> theme.text @@ -220,15 +248,15 @@ private fun tabIndicatorRow(state: TabIndicatorState, needsAttention: Boolean) { overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = Sizing.panelWidthSm), ) - if (state.isActive && state.closeable) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_close_tab), + if (state.closeable) { + Text( + text = "×", + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, modifier = Modifier - .size(Sizing.minTouchTarget) .clickable(onClick = state.onClose, role = Role.Button) - .padding((Sizing.minTouchTarget - Sizing.iconXs) / 2), - tint = theme.textMuted, + .semantics { contentDescription = closeLabel }, ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index d74a3d35..519234a3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,6 +46,7 @@ import dev.blazelight.p4oc.ui.screens.settings.* import dev.blazelight.p4oc.ui.screens.terminal.TerminalScreen import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import dev.blazelight.p4oc.ui.workspace.WorkspaceViewModel +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @@ -173,6 +175,7 @@ fun TabNavHost( ) ) { composable(Screen.Home.route) { + val homeCoroutineScope = rememberCoroutineScope() homeScreen( summary = HomeSummaryBuilder.build( HomeSummaryInput( @@ -186,6 +189,10 @@ fun TabNavHost( onOpenFiles = { onNewFilesTab() }, onOpenTerminal = { onNewTerminalTab() }, onChooseTarget = onNewFilesTab, + onRefresh = { + homeCoroutineScope.launch { workspaceOwner.sessionRepository.refresh() } + }, + onSettings = { navController.navigate(Screen.Settings.route) }, ), ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt index bcf84528..7898fb03 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt @@ -74,6 +74,12 @@ object Sizing { // Component-specific val switchCellWidth: Dp = 40.dp // TuiSwitch ON/OFF cell width + // Knob-slider switch (design): 36x20 track, 14x14 sliding knob, 2dp inset + val switchTrackWidth: Dp = 36.dp + val switchTrackHeight: Dp = 20.dp + val switchKnobSize: Dp = 14.dp + val switchKnobInset: Dp = 2.dp + val tabBarHeight: Dp = 30.dp // Tab bar strip height (design) val treeIndent: Dp = 24.dp // Session tree indentation per level val chipMaxWidth: Dp = 150.dp // Project chip max width val tabHeight: Dp = 22.dp // Tab bar item height diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b12345f..0632d523 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ OpenCode server %1$s is available OpenCode server updated to %1$s Home - Search every server, session, or workspace + Search sessions and workspaces %1$d sessions · %2$d workspaces ◈ Shared @@ -31,6 +31,7 @@ Run OpenCode directly on your device using Termux. Network Server Connect to an OpenCode server running on your network. + connect to a running opencode server Recent Servers Your servers Tap a server to connect. More actions are available from its menu. @@ -84,9 +85,12 @@ Connect from this device Enter http://<your-pc-ip>:4096\nin the URL field above.\nUsername: opencode\nPassword: <your password> Tip - --hostname 0.0.0.0 is required so the server is reachable from your phone, not just localhost. Use your computer\'s local IP (e.g., 192.168.x.x) when connecting. - $ ip addr # Linux\n$ ifconfig # macOS\n$ ipconfig # Windows - Test from your phone browser:\nhttp://<your-ip>:4096/global/health + --hostname 0.0.0.0 is required so the server is reachable from your phone, not just localhost. Use your computer\'s local IP (e.g. 192.168.x.x). + + --hostname 0.0.0.0 + $ ip addr # Linux\n$ ifconfig # macOS\n$ ipconfig # Windows + Test from your phone browser: + http://<your-ip>:4096/global/health Step 1: Install Termux @@ -125,9 +129,33 @@ Hide password Allow self-signed certificate Skip TLS verification (use only on trusted networks) - + + + P4OC + Pocket for OpenCode + SERVER URL + · required + USERNAME + PASSWORD + show + hide + TLS + skip TLS verification + The certificate is not verified. Use only on a network you trust. + username · password + scanning nearby servers + retry + use + Settings + Server · %1$s + Routing · per server + All + Mentions + Off + Add a server to route its notifications. + App Providers Connect and authenticate AI providers Model Controls @@ -908,16 +936,22 @@ In Change Choose a server and workspace + Choose a workspace + server · project + 1 workspace + %1$d workspaces + + %1$d more Filter workspaces… No matching workspaces No servers configured. Add a server to start work. New chat Files Terminal - Open in this exact server and workspace + New + Existing Existing work Sessions - Browse sessions in this exact workspace + Browse existing sessions %1$s, %2$d workspaces %1$s. %2$s Close server details diff --git a/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt index e6f0251d..5ea096c0 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.core.notification +import dev.blazelight.p4oc.core.datastore.NotificationRoutingMode import dev.blazelight.p4oc.core.datastore.NotificationSettings import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey @@ -23,6 +24,24 @@ class NotificationEventObserverTest { assertTrue(shouldEmitCompletionFeedback(enabled, isInForeground = false)) } + @Test + fun `routing All delivers awaiting-input and completion`() { + assertTrue(shouldDeliverAwaitingInput(NotificationRoutingMode.All)) + assertTrue(shouldDeliverCompletion(NotificationRoutingMode.All)) + } + + @Test + fun `routing Mentions delivers awaiting-input but suppresses completion`() { + assertTrue(shouldDeliverAwaitingInput(NotificationRoutingMode.Mentions)) + assertFalse(shouldDeliverCompletion(NotificationRoutingMode.Mentions)) + } + + @Test + fun `routing Off suppresses everything`() { + assertFalse(shouldDeliverAwaitingInput(NotificationRoutingMode.Off)) + assertFalse(shouldDeliverCompletion(NotificationRoutingMode.Off)) + } + @Test fun `completion consumes busy state exactly once`() { val tracker = CompletionTracker() diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt index 0caaa370..537b608e 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt @@ -375,6 +375,27 @@ class HomeSummaryBuilderTest { assertEquals(emptyList(), filtered.workspaces) } + @Test + fun `launcher shows connected servers only when more than two are connected`() { + val summaries = (1..4).map { index -> + val (_, serverRef) = server("http://server-$index.example.com", "Server $index") + ServerSummary( + serverRef = serverRef, + displayName = serverRef.displayName, + connectionState = if (index < 4) ConnectionState.Connected else ConnectionState.Disconnected, + sessionCount = index, + openTabCount = 0, + isLoading = false, + ) + } + + assertEquals(emptyList(), connectedServersForLauncher(summaries.take(2))) + assertEquals( + listOf("Server 1", "Server 2", "Server 3"), + connectedServersForLauncher(summaries).map { it.displayName }, + ) + } + private fun build( servers: List, tabs: List = emptyList(), diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ConnectFormValidationTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ConnectFormValidationTest.kt new file mode 100644 index 00000000..56100e5e --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ConnectFormValidationTest.kt @@ -0,0 +1,72 @@ +package dev.blazelight.p4oc.ui.screens.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.net.SocketTimeoutException +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class ConnectFormValidationTest { + @Test + fun `a complete form has no error`() { + val state = ServerUiState(remoteUrl = "http://box.local:4096", username = "opencode", password = "hunter2") + + assertNull(state.connectFormError()) + } + + @Test + fun `the url is reported before the credentials`() { + val state = ServerUiState(remoteUrl = " ", username = "", password = "") + + assertEquals("Enter a server URL", state.connectFormError()) + } + + @Test + fun `username and password are both required`() { + val base = ServerUiState(remoteUrl = "http://box.local:4096") + + assertEquals("Enter a username (usually opencode)", base.copy(username = "", password = "p").connectFormError()) + assertEquals("Enter the server password", base.copy(username = "opencode", password = "").connectFormError()) + } + + @Test + fun `username defaults to opencode`() { + assertEquals("opencode", ServerUiState().username) + } + + @Test + fun `tls options stay hidden until an attempt fails on trust`() { + assertFalse(ServerUiState().showTlsOptions) + } + + @Test + fun `certificate failures are recognised through the cause chain`() { + val wrapped = IOException("probe failed", SSLHandshakeException("cert path")) + + assertTrue(wrapped.isTlsTrustFailure()) + assertTrue(SSLPeerUnverifiedException("no peer").isTlsTrustFailure()) + assertTrue(CertificateException("bad cert").isTlsTrustFailure()) + assertTrue(CertPathValidatorException("untrusted anchor").isTlsTrustFailure()) + } + + @Test + fun `ordinary connection failures are not treated as trust failures`() { + assertFalse(SocketTimeoutException("timeout").isTlsTrustFailure()) + assertFalse(IOException("connection refused").isTlsTrustFailure()) + } + + @Test + fun `a self-referencing cause chain terminates`() { + val outer = IOException("outer") + val inner = IOException("inner", outer) + outer.initCause(inner) + + assertFalse(outer.isTlsTrustFailure()) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt index cf914302..7d27dbb2 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt @@ -253,7 +253,7 @@ class StartWorkContextTest { } @Test - fun `picker filter scopes directories to selected server and pins global`() { + fun `picker search keeps only servers with matching workspaces and expands them`() { val groups = buildStartWorkPickerGroups( servers = listOf( Triple(alpha.endpointKey, "Alpha", "A"), @@ -261,23 +261,31 @@ class StartWorkContextTest { ), openTargets = listOf( StartWorkTarget(alpha, WorkspaceKey.Directory("/repo/needle-alpha")), - StartWorkTarget(beta, WorkspaceKey.Directory("/repo/needle-beta")), + StartWorkTarget(beta, WorkspaceKey.Directory("/repo/other")), ), knownHomeTargets = emptyList(), ) - val filtered = StartWorkPickerState(alpha.endpointKey, "needle").filteredTargets(groups) + val rows = buildStartWorkPickerRows( + groups, + StartWorkPickerViewState( + query = "needle", + // A collapse the user made earlier must not hide a search hit. + expandedOverrides = mapOf(alpha.endpointKey to false), + ), + ) - assertEquals(WorkspaceKey.Global, filtered.first().workspaceKey) + assertEquals(1, rows.size) + assertEquals(alpha.endpointKey, rows[0].group.server.endpointKey) + assertTrue(rows[0].expanded) assertEquals( - listOf(WorkspaceKey.Global, WorkspaceKey.Directory("/repo/needle-alpha")), - filtered.map { it.workspaceKey }, + listOf(WorkspaceKey.Directory("/repo/needle-alpha")), + rows[0].visibleTargets.map { it.workspaceKey }, ) - assertTrue(filtered.all { it.serverRef.endpointKey == alpha.endpointKey }) } @Test - fun `picker filter matches workspace path case insensitively`() { + fun `picker search matches workspace path case insensitively`() { val groups = buildStartWorkPickerGroups( servers = listOf(Triple(alpha.endpointKey, "Alpha", "A")), openTargets = listOf( @@ -287,12 +295,72 @@ class StartWorkContextTest { knownHomeTargets = emptyList(), ) - val filtered = StartWorkPickerState(alpha.endpointKey, "p4oc").filteredTargets(groups) + val rows = buildStartWorkPickerRows(groups, StartWorkPickerViewState(query = "p4oc")) assertEquals( - listOf(WorkspaceKey.Global, WorkspaceKey.Directory("/Projects/Android/P4OC")), - filtered.map { it.workspaceKey }, + listOf(WorkspaceKey.Directory("/Projects/Android/P4OC")), + rows.single().visibleTargets.map { it.workspaceKey }, + ) + } + + @Test + fun `collapsed server hides its workspaces but keeps the match count`() { + val groups = buildStartWorkPickerGroups( + servers = listOf( + Triple(alpha.endpointKey, "Alpha", "A"), + Triple(beta.endpointKey, "Beta", "B"), + ), + openTargets = listOf(StartWorkTarget(alpha, WorkspaceKey.Directory("/repo"))), + knownHomeTargets = emptyList(), ) + + val rows = buildStartWorkPickerRows( + groups, + StartWorkPickerViewState(defaultExpandedEndpointKey = beta.endpointKey), + ) + + val alphaRow = rows.single { it.group.server.endpointKey == alpha.endpointKey } + assertTrue(!alphaRow.expanded) + assertTrue(alphaRow.visibleTargets.isEmpty()) + assertEquals(0, alphaRow.hiddenCount) + // Global + /repo are still counted so the header can show "2 workspaces". + assertEquals(2, alphaRow.matchCount) + assertTrue(rows.single { it.group.server.endpointKey == beta.endpointKey }.expanded) + } + + @Test + fun `expanded server pages workspaces and show-all lifts the cap`() { + val directories = (1..PICKER_WORKSPACE_PAGE_SIZE + 5).map { + StartWorkTarget(alpha, WorkspaceKey.Directory("/repo/p$it")) + } + val groups = buildStartWorkPickerGroups( + servers = listOf(Triple(alpha.endpointKey, "Alpha", "A")), + openTargets = directories, + knownHomeTargets = emptyList(), + ) + val total = PICKER_WORKSPACE_PAGE_SIZE + 6 // directories + the global entry + + val capped = buildStartWorkPickerRows(groups, StartWorkPickerViewState()).single() + assertEquals(PICKER_WORKSPACE_PAGE_SIZE, capped.visibleTargets.size) + assertEquals(total - PICKER_WORKSPACE_PAGE_SIZE, capped.hiddenCount) + + val expanded = buildStartWorkPickerRows( + groups, + StartWorkPickerViewState(showAllEndpointKeys = setOf(alpha.endpointKey)), + ).single() + assertEquals(total, expanded.visibleTargets.size) + assertEquals(0, expanded.hiddenCount) + } + + @Test + fun `a single server is expanded without an explicit default`() { + val groups = buildStartWorkPickerGroups( + servers = listOf(Triple(alpha.endpointKey, "Alpha", "A")), + openTargets = listOf(StartWorkTarget(alpha, WorkspaceKey.Directory("/repo"))), + knownHomeTargets = emptyList(), + ) + + assertTrue(buildStartWorkPickerRows(groups, StartWorkPickerViewState()).single().expanded) } @Test diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..fbbeef46 --- /dev/null +++ b/opencode.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "claude_design": { + "type": "remote", + "url": "https://api.anthropic.com/v1/design/mcp", + "enabled": true + } + }, + "model": "llmproxy/alias/gpt" +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..dd311cee --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "stage-chapters": { + "source": "ReviewStage/stage-cli", + "sourceType": "github", + "skillPath": "skills/stage-chapters/SKILL.md", + "computedHash": "f08eb857f6da4ab880303cc64f14c13e126bd7822db304a653515cd41c5109d2" + } + } +}