Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bce306238b fix(server): keep a gotgenes foreground answer on replay
A foreground @gotgenes/pi-subagents child finds its session file only
through the live runtime marker, which history does not keep. Without a
file, emit the spawn result text as the child's assistant message.
2026-10-30 12:01:23 +01:00
Mohamed Boudra c1bcfd930b fix(server): settle child compactions and keep sub-agent cards stable
- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.
2026-10-30 11:47:33 +01:00
Mohamed Boudra 6953ba6506 fix(server): put a tintinweb foreground answer in the child timeline
A foreground @tintinweb/pi-subagents child has no session file, so its
answer only exists in the Agent tool result. Emit that text as the
child's assistant message when the child finishes. Tag every sub_agent
producer's `log: ""` with COMPAT(subagent-log) so the shim removal
finds them.
2026-10-30 09:49:51 +01:00
Mohamed Boudra a68a0c2c5f fix: make sub-agent cards launch markers
Sub-agent tool calls no longer accumulate the child's activity into
`log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child
starts and when its label or status changes; the child's messages and
tool calls stay in the sub-agent's own timeline. The app no longer
renders a sub-agent log.

The wire schema makes `log` optional. Daemons still send `log: ""` so
apps that require the field keep parsing the card.
2026-10-30 08:35:04 +01:00
297 changed files with 5642 additions and 17956 deletions
+2 -2
View File
@@ -281,10 +281,10 @@ jobs:
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-latest, file observation)
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-latest
runs-on: macos-14
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -100,10 +100,10 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-latest
- runner: macos-14
electron_arch: arm64
node_heap_mb: 4096
- runner: macos-26-intel
- runner: macos-15-intel
electron_arch: x64
node_heap_mb: 8192
permissions:
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
run: nix build .#desktop -o result-desktop
build-desktop-darwin:
runs-on: macos-latest
runs-on: macos-14
steps:
- uses: actions/checkout@v4
-1
View File
@@ -52,7 +52,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
-12
View File
@@ -61,18 +61,6 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
-10
View File
@@ -493,16 +493,6 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Outbound networking
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are treated as unset, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
+3 -3
View File
@@ -232,7 +232,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:<provider-id>`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
@@ -260,9 +260,9 @@ Paseo passes those variables through to the Codex app-server process **and** map
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider = "paseo:my-codex"
model_provider = "my-codex"
[model_providers."paseo:my-codex"]
[model_providers.my-codex]
name = "My Codex"
base_url = "https://custom-relay.example.com/v1"
wire_api = "responses"
-1
View File
@@ -38,7 +38,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
-14
View File
@@ -35,20 +35,6 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android background fold recovery, install the release APK on a foldable AVD, open a
connected workspace, close the keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
```
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
changes posture, resumes immediately, and asserts compact/wide workspace controls without
restarting the process. It writes snapshots and native failure state under
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
reload cannot update it.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
+1 -1
View File
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
-41
View File
@@ -52,47 +52,6 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Terminal ANSI replay
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
only subsequent live output, without the existing screen or history.
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
advertising the capability. The SDK keeps the established subscribe request against older
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
## Binary chunking
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
negotiate connection-scoped framing. Application serializers and routed envelopes use
the same boundary. Small messages and handshakes keep their existing representation.
Older peers retain existing frame limits.
The reserved binary opcode `0x31` is followed by version `1`, original kind
(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an
unchanged slice. There is no total length or chunk count; a future receiver can
consume slices incrementally. Today the receiver buffers up to the existing
100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave.
Disconnect clears partial state and negotiation.
The lower transport supplies its plaintext frame budget, including encryption
overhead. The sender waits for drain between chunks while ordinary messages keep
the existing delivery path. Slow-client protections count messages queued behind
an active transfer as well as bytes already in the socket.
The framing module never selects an encryption construction.
Native uploads retain the platform's existing queue limits. React Native's
WebSocket does not report its queued bytes, so the client cannot wait for native
drain. Android's installed OkHttp transport closes when a send would put more
than 16 MiB in its queue. The native transport disables outbound chunking, including
on routed channels, and retains its existing single-frame admission errors.
Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
+6 -10
View File
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ framed local socket → daemon main process
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
@@ -19,15 +19,11 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## Invariants (the easy-to-break ones)
- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations.
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer.
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
-65
View File
@@ -1,65 +0,0 @@
# Terminal rendering
Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/headless` buffer in JS and draw it with the `PaseoTerminal` native view in `packages/app/modules/paseo-terminal`: CoreText on iOS, `Canvas.drawTextRun` on Android. `src/terminal/native-renderer/terminal-frame.ts` turns the visible buffer into frames, and both native views take the same props.
## xterm owns the grid
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
## Ink outside the row
Marks stacked above or below a letter, Arabic letter tails and some fallback glyphs ink into the rows next to theirs. Neither view clips a row to its own bounds, because a row clip also cuts marks and Arabic dots. Rows draw top to bottom, so a later row's cell backgrounds cover the ink that reaches into it.
Android redraws every row on each frame, so overhanging ink cannot go stale. Its row `RenderNode`s are created with `setClipToBounds(false)`. The default would clip only unselected rows, because selected rows are drawn straight onto the view's canvas.
iOS repaints only the rows that changed. Each `TerminalRowLayout` records how far its glyphs ink above and below the row. A changed row invalidates its old and new ink extent, and `draw(_:)` redraws every row whose ink extent meets the dirty rect. Invalidating only the row's own rect left an erased row's marks in the row next to it, erased a neighbour's marks when a row was rewritten, and showed or hid overhang depending on which rows changed together.
## Mixed-direction text
Rows draw left to right in logical order on both platforms. Terminal bidi escape modes are not implemented. On iOS the row needs both the `kCTWritingDirection` override and an LTR paragraph base direction. Without the base direction, bidi rule L1 moves trailing spaces ahead of a row that starts with Arabic or Hebrew, and the whole row shifts.
Arabic still gets the contextual form its logical neighbours select, but in left-to-right order, so letters join on the wrong side. Correct visual Arabic needs implicit bidi per row: reorder RTL runs for display and map cursor, selection and hit-testing through the visual-to-logical index. That changes which column a character appears in, so it has to be decided as product behaviour first. Shaping alone cannot fix it.
## Render regression
The scripts live in `packages/app/e2e/mobile/terminal-rendering`. Run them from `packages/app` on the daemon's host, starting from an idle, focused terminal with an empty prompt.
`render.mjs` prints a mixed-script fixture, screenshots it with agent-device, and checks the geometry with `check.mjs`. The check rebuilds the expected buffer with `@xterm/headless` and measures the grid from a ruler row. It checks that:
- every grapheme cluster has ink inside its own columns, and blank cells stay blank;
- on the accent rows, every cell is drawn in its own colour, including cells inside one cluster;
- the cursor is drawn after the text;
- every row is exposed as `terminal-row-<n>`;
- selecting one cell of a conjunct highlights only that cell.
On iOS the screenshot is taken at 3 pixels per point (`PASEO_TERMINAL_RENDERING_PIXEL_DENSITY`), because the simulator's default is one pixel per point and a glyph is then about 12 pixels. Focus the terminal before a run: with the simulator's hardware keyboard connected the key bar shows without a focused input, and `type` fails. In a debug build, the dev client's floating gear button covers the right end of the first row, so ruler cells there fail.
```bash
PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
```
Geometry passes when a cell holds the wrong glyph. `identity.mjs` judges each cluster of a `render.mjs` run against pango's rendering of the same characters in the device's fonts. The reference is cut at the edges of the cluster's columns, placed as the renderer places it, because glyphs such as Devanagari letters ink past their advance and the device cuts them there too. A shape passes when its coverage correlates with its own reference at 0.6 or more, and no other cluster of the corpus matches it better. Box and block characters are judged against their Unicode definition. Shades are judged by even density over a 4×4 grid, because the device and pango fall back to different shade fonts. Clusters that pango draws without ink are reported as unjudged instead: flags on both platforms and Apple Color Emoji. The `rect` and `swap` controls fill every cluster with white, or judge each one against another cluster. Both must fail every cluster, which shows the oracle refuses ink that is present but wrong. An Arabic letter is compared with all four of its positional forms, so the oracle shows the right letter is drawn, not that it has the right form. Ink that a neighbouring row draws into a cluster's cells counts as that cluster's ink. On iOS the tail of `م` in the Arabic row reaches one pixel into the `न` below it, so `न` fails although both rows are drawn correctly.
```bash
FONTCONFIG_FILE=fonts.conf node e2e/mobile/terminal-rendering/identity.mjs <run dir> [none|rect|swap]
```
`fonts.conf` points fontconfig at the device's font files and lists, for `monospace`, the terminal font followed by the device's fallbacks for Hangul, Arabic, Devanagari, CJK, emoji and symbols:
- **Android:** pull the fonts from `/system/fonts` with `adb pull`. The terminal font is Droid Sans Mono.
- **iOS:** copy the simulator runtime's fonts from `RuntimeRoot/System/Library/Fonts`. The terminal font is Menlo. PingFang stores its outlines in Apple's `hvgl` table, which FreeType cannot draw, so the CJK references come from Apple SD Gothic Neo instead. `語` then scores below the threshold although the device draws it correctly.
`overhang.mjs` prints rows whose marks reach the rows next to them. In one update it writes into two rows that hold a neighbour's marks and erases two rows whose marks reach a blank row. The marks must be drawn before the update, stay drawn in the written rows, and leave no ink behind the erased rows. Run it like `render.mjs`.
`reattach.mjs` checks the restore size. It changes the PTY size by opening the keyboard (`RESIZE=keyboard`) or folding an Android foldable (`RESIZE=fold`), relaunches the app, and compares the client's rows with `paseo terminal capture`. A trial where no terminal rows appear, for example after a launch crash, counts as never attached and fails the run.
+1 -15
View File
@@ -15,8 +15,6 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
## Record, socket and secret
@@ -42,22 +40,10 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Display replay
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
+4 -10
View File
@@ -21,16 +21,10 @@ The invariants are:
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination.
Every item is bounded before it enters either delivery path, because a timeline page is one
WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up
to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker
element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The
budget does not bound wide objects or multi-byte text. The same
bounded item is used for runtime timeline rows and live stream events. Provider history hydration
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
again to assistant and reasoning rows the projection merges from many chunks.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
tool payload.
## Presence is not delivery
+1 -1
View File
@@ -1 +1 @@
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
-32
View File
@@ -14740,12 +14740,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
@@ -37709,7 +37703,6 @@
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
@@ -40689,7 +40682,6 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -40698,7 +40690,6 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -40716,7 +40707,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -41290,15 +41280,6 @@
"url": "https://opencollective.com/vitest"
}
},
"packages/server/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41368,19 +41349,6 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -9,11 +9,9 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
await expect(
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
).toHaveAccessibleName("System");
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
await themeTrigger.click();
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("appearance-theme-picker.png"),
fullPage: true,
@@ -18,7 +18,7 @@ function visibleMainPane(page: Page) {
}
function composerChangesPill(page: Page) {
return page.getByRole("button", { name: "Open Changes tab", exact: true });
return page.getByTestId("composer-diff-stat-pill");
}
async function revealComposerChangesInExplorer(page: Page) {
@@ -498,7 +498,6 @@ test.describe("send review feedback", () => {
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
page,
}) => {
test.setTimeout(120000);
const workspace = await seedChangedAgent("review-feedback-multiple-");
const other = await seedChangedAgent("review-feedback-other-");
try {
@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
@@ -14,10 +13,6 @@ import {
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -124,51 +119,3 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
);
return paseoHome;
}
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
// A stopped worker deterministically outlives the old helper's five-second
// supervisor-only kill and logs into its deleted home when resumed.
cleanupTest(
"isolated cleanup prevents a stopped worker from recreating its deleted home",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const workerPid = await readWorkerPid(daemon);
try {
suspendDaemonProcess(workerPid);
await daemon.close();
resumeDaemonProcess(workerPid);
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(workerPid);
await daemon.close();
await rm(daemon.paseoHome, { recursive: true, force: true });
}
},
);
cleanupTest(
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const supervisorPid = daemon.getPid()!;
const workerPid = await readWorkerPid(daemon);
try {
await openLongLivedTerminal(daemon);
const terminalProcesses = await readTerminalProcessIds(daemon);
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
suspendDaemonProcess(supervisorPid);
await daemon.close();
const writerStates = await Promise.all(
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
);
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(supervisorPid);
await daemon.close();
}
},
);
@@ -1,4 +1,3 @@
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
import {
composerLocator,
enterComposerFullscreen,
@@ -98,7 +97,6 @@ test.describe("New workspace composer draft", () => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
try {
await startWithoutRememberedModel(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
@@ -106,7 +104,6 @@ test.describe("New workspace composer draft", () => {
projectDisplayName: project.projectDisplayName,
});
await chooseModel(page, "mock", "Ten second stream");
const composer = page.getByRole("textbox", { name: "Message agent..." });
const createButton = page.getByTestId("workspace-create-submit");
await expect(composer).toBeEditable({ timeout: 30_000 });
-231
View File
@@ -1,231 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, metroTest as test } from "../support/fixtures";
import {
addProjectFromSetup,
continueSetup,
expectSetupStep,
finishSetup,
goToSetupStep,
openAppAsNewUser,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
skipSetup,
startSetupHost,
} from "../support/helpers/onboarding";
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsHostSection } from "../support/helpers/settings";
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
test("a new user is walked through setup once", async ({ page }, testInfo) => {
const host = await startSetupHost("fresh");
const repo = await createTempGitRepo("onboarding-folder-");
try {
await openAppAsNewUser(page, host);
await test.step("setup opens on Appearance and a theme applies at once", async () => {
await expectSetupStep(page, "Welcome to Paseo");
await pickTheme(page, "Midnight");
await expect(
setupModal(page).getByText(/^Run Claude Code, Codex and other agents/),
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
});
await test.step("Providers shows the same provider list as Settings", async () => {
await continueSetup(page);
await expectSetupStep(page, "Your agents");
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
});
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
await continueSetup(page);
await expectSetupStep(page, "Add your projects");
await expect(
setupModal(page).getByText("No recent projects found", { exact: true }),
).toBeVisible({ timeout: 60_000 });
await addProjectFromSetup(page, repo.path);
await expectSetupStep(page, "Add your projects");
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
});
await test.step("Orchestration shows the Paseo tools switch", async () => {
await continueSetup(page);
await expectSetupStep(page, "Let agents use Paseo");
await expect(
setupModal(page).getByRole("switch", { name: "Inject Paseo tools", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
});
await test.step("Skip setup closes it for good", async () => {
await skipSetup(page);
await page.reload();
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({
hasText: path.basename(repo.path),
}),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page)).toHaveCount(0);
});
} finally {
await host.close();
await repo.cleanup();
}
});
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
const repo = await createTempGitRepo("onboarding-history-");
const repoName = path.basename(repo.path);
const subdirectory = path.join(repo.path, "packages", "web");
await mkdir(subdirectory, { recursive: true });
const worktree = `${repo.path}-fix`;
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
const scratch = await createTempDirectory("onboarding-scratch-");
const scratchName = path.basename(scratch.path);
const host = await startSetupHost("history", {
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
withBrokenProvider: true,
});
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await goToSetupStep(page, "Projects");
await expectSetupStep(page, "Add your projects");
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
await expect(projectSuggestion(page, repoName)).toContainText("3 sessions");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(setupModal(page).getByRole("checkbox")).toHaveCount(2);
});
await test.step("a provider that cannot read its history offers Retry", async () => {
await expect(
setupModal(page).getByText("Couldn't read Broken ACP history", { exact: true }),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page).getByRole("button", { name: "Retry" })).toBeVisible();
});
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
await expect(
setupModal(page).getByRole("button", { name: "Add another project…", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
});
await test.step("a folder that cannot be added keeps the user on the step", async () => {
await scratch.cleanup();
await setupPrimaryButton(page, "Add 2 projects").click();
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
await expectSetupStep(page, "Add your projects");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(projectSuggestion(page, scratchName)).toContainText("Directory not found");
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
});
await test.step("with nothing checked, Continue moves on without adding", async () => {
await projectSuggestion(page, scratchName).click();
await setupPrimaryButton(page, "Continue").click();
await expectSetupStep(page, "Let agents use Paseo");
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
).toBeVisible({ timeout: 60_000 });
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
).toHaveCount(0);
});
} finally {
await host.close();
await rm(worktree, { recursive: true, force: true });
await repo.cleanup();
await scratch.cleanup();
}
});
test("an existing user only sees setup when they ask for it", async ({ page }) => {
const host = await startSetupHost("existing");
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
try {
await openAppAsNewUser(page, host);
await expect(
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page)).toHaveCount(0);
await showSetupAgainFromSettings(page);
await expectSetupStep(page, "Welcome to Paseo");
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
await host.close();
}
});
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
page,
}, testInfo) => {
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await test.step("Paseo tools turned on in setup", async () => {
await goToSetupStep(page, "Orchestration");
await expectSetupStep(page, "Let agents use Paseo");
await expect(paseoToolsSwitch(setupModal(page))).not.toBeChecked();
await paseoToolsSwitch(setupModal(page)).click();
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
});
await test.step("Phone asks for relay consent, then shows the pairing QR", async () => {
await continueSetup(page);
await expectSetupStep(page, "Take your agents with you");
await expect(setupModal(page).getByText("Enable relay?", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("5-phone-consent.png") });
expect(await openTailscaleDocsFromSetup(page)).toBe(
"https://paseo.sh/docs/connectivity#tailscale",
);
await enableRelayAndExpectOffer(page);
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
});
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
await finishSetup(page);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await openSettingsHostSection(page, host.serverId, "agents");
await expect(paseoToolsSwitch(page)).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
});
} finally {
await host.close();
}
});
+24 -13
View File
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -68,7 +69,8 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
@@ -77,14 +79,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
});
await test.step("a contributed light theme uses the light palette", async () => {
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
await latteItem.click();
await expect(latteItem).toBeChecked();
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(mochaItem).toBeChecked();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
@@ -93,13 +95,15 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await test.step("the selection survives a reload", async () => {
await page.reload();
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
});
await test.step("removing the plugin falls back to the default theme", async () => {
await client.removePlugin(PLUGIN_ID);
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -143,7 +147,15 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
return () => {};
}`;
test("reaches the last theme when a plugin contributes more themes than fit", async ({
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
@@ -163,16 +175,15 @@ test("reaches the last theme when a plugin contributes more themes than fit", as
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
await lastTheme.scrollIntoViewIfNeeded();
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await lastTheme.click();
await expect(lastTheme).toBeChecked();
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
@@ -313,12 +313,12 @@ test.describe("Settings sidebar navigation", () => {
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Highlight theme:/).click();
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
@@ -1,10 +1,5 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
@@ -64,65 +59,4 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
for (const { count, retainedMarker } of [
{ count: 3000, retainedMarker: "history-0001界🙂" },
{ count: 12050, retainedMarker: "history-3000界🙂" },
]) {
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
page,
}) => {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
});
const terminal = await harness.createTerminal({
name: `history-${count}`,
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
],
});
const last = `history-${String(count).padStart(4, "0")}界🙂`;
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
const restored = await getTerminalBufferText(page);
expect(restored).toContain(retainedMarker);
expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual(
Math.min(count, 10000),
);
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
});
}
for (const input of [
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
{
name: "program blinking underline",
presentation: "\x1b[3 q",
shape: "underline" as const,
blinking: true,
},
]) {
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
const terminal = await harness.createTerminal({
name: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
});
}
});
@@ -1,69 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,31 +441,3 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
-104
View File
@@ -1,104 +0,0 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
@@ -38,9 +38,12 @@ async function expectDarkIcons(expected, context) {
assert.equal(windowState(), expected, context);
}
// Settings > Appearance shows every theme as a tile in one radio group.
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
@@ -1,311 +0,0 @@
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import xterm from "@xterm/headless";
import { PNG } from "pngjs";
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
/** A pixel is ink when one channel differs from the terminal background by more than this. */
const INK_THRESHOLD = 70;
/** Ink pixels a printed grapheme cluster needs inside its own columns. */
const MIN_CLUSTER_INK = 3;
/** How far a pixel may sit from the background-to-color blend line and still count as that color. */
const BLEND_TOLERANCE = 40;
/** Pixels of another cell's color tolerated in a cell, for antialiasing at the cell edge. */
const MAX_FOREIGN_INK = 2;
const graphemes = new Intl.Segmenter("und", { granularity: "grapheme" });
/**
* Checks a screenshot of the fixture against the cells xterm assigns: every printed grapheme
* cluster has ink inside its own columns, blank cells stay blank, and the cursor sits right after
* the text. A cluster that spans cells, such as a Devanagari conjunct, is shaped as one unit, so
* it is judged across all its columns. On rows that use the fixture's accent color, every cell
* must be drawn in its own color, including cells that share a shaped cluster.
*
* These are geometry and color checks: they cannot tell a correct glyph from a wrong one.
*
* `rows` maps a terminal row index to its rect in screenshot pixels.
*/
export async function checkTerminalScreenshot({ png, size, rows }) {
const terminal = await fixtureTerminal(size);
assert.ok(rows.get(0), "The ruler row is not exposed as terminal-row-0");
const grid = measureGrid(png, rows.get(0));
const { failures, slots } = checkRows({ png, terminal, rows, grid });
terminal.dispose();
return { grid, failures, slots };
}
/** Where the harness selects: the first cell of the conjunct that starts the `select` line. */
export async function selectionTarget({ size }) {
const terminal = await fixtureTerminal(size);
const row = lineNames(terminal.buffer.active, terminal.buffer.active.cursorY + 1).indexOf(
SELECT_LINE,
);
terminal.dispose();
return { row, col: 0, clusterColumns: 2 };
}
/**
* Checks a screenshot taken with only the first cell of the `select` conjunct selected: that cell
* has the selection background and the conjunct's second, unselected cell keeps its accent color.
*/
export function checkPartialSelection({ png, rows, grid, target }) {
const rect = rows.get(target.row);
if (!rect) return [{ row: target.row, kind: "missing-row" }];
const cell = (col) => ({
left: grid.left + col * grid.cellWidth + 1,
right: grid.left + (col + 1) * grid.cellWidth - 1,
top: rect.y,
bottom: rect.y + rect.height,
});
const selected = cell(target.col);
const unselected = cell(target.col + 1);
const selectionBackground = pixelAt(png, selected.left + 1, selected.top + 1);
const unselectedBackground = pixelAt(png, unselected.left + 1, unselected.top + 1);
if (
!differs(selectionBackground, grid.background) ||
differs(unselectedBackground, grid.background)
) {
return [{ row: target.row, col: target.col, kind: "selection-not-set-up" }];
}
const failures = [];
const ownInk = countColor(png, unselected, grid.background, ACCENT);
if (ownInk < MIN_CLUSTER_INK) {
failures.push({ row: target.row, col: target.col + 1, kind: "unselected-cell-lost-color" });
}
return failures;
}
async function fixtureTerminal(size) {
const terminal = new xterm.Terminal({
cols: size.columns,
rows: size.rows,
allowProposedApi: true,
});
await new Promise((done) =>
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
);
return terminal;
}
export function writeOverlay(png, slots, path) {
const out = new PNG({ width: png.width, height: png.height });
png.data.copy(out.data);
for (const slot of slots) {
const color = slot.ok ? [0, 200, 0] : [255, 0, 0];
const { left, right, top, bottom } = slot.box;
for (let x = Math.ceil(left); x < right; x += 1) {
for (const y of [Math.ceil(top), Math.floor(bottom) - 1]) {
const offset = (out.width * y + x) * 4;
out.data.set(color, offset);
}
}
if (!slot.ok) {
for (let y = Math.ceil(top); y < bottom; y += 1) {
for (const x of [Math.ceil(left), Math.floor(right) - 1]) {
out.data.set(color, (out.width * y + x) * 4);
}
}
}
}
writeFileSync(path, PNG.sync.write(out));
}
function inkAt(png, x, y, background) {
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
return (
Math.abs(png.data[offset] - background[0]) > INK_THRESHOLD ||
Math.abs(png.data[offset + 1] - background[1]) > INK_THRESHOLD ||
Math.abs(png.data[offset + 2] - background[2]) > INK_THRESHOLD
);
}
/** Pixels in `box` that differ from `background` in some channel by more than the ink threshold. */
export function countInk(png, box, background) {
let count = 0;
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
if (inkAt(png, x, y, background)) count += 1;
}
}
return count;
}
function differs(a, b) {
return a.some((channel, index) => Math.abs(channel - b[index]) > INK_THRESHOLD);
}
/** Distance from `pixel` to the nearest blend of `background` toward `color`. */
function blendDistance(pixel, background, color) {
const direction = color.map((channel, index) => channel - background[index]);
const length = direction.reduce((sum, value) => sum + value * value, 0);
const offset = pixel.map((channel, index) => channel - background[index]);
const along = offset.reduce((sum, value, index) => sum + value * direction[index], 0);
const alpha = length === 0 ? 0 : Math.min(1, Math.max(0, along / length));
return Math.hypot(...offset.map((value, index) => value - alpha * direction[index]));
}
/** Ink pixels in `box` that are a blend of the background toward `color`. */
function countColor(png, box, background, color, other) {
let count = 0;
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
if (!inkAt(png, x, y, background)) continue;
const pixel = pixelAt(png, x, y);
const distance = blendDistance(pixel, background, color);
if (distance > BLEND_TOLERANCE) continue;
if (other && blendDistance(pixel, background, other) <= distance) continue;
count += 1;
}
}
return count;
}
function pixelAt(png, x, y) {
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
return [png.data[offset], png.data[offset + 1], png.data[offset + 2]];
}
/** Reads the grid origin and cell width from a ruler row of `RULER_CELLS` full blocks. */
export function measureGrid(png, rulerRect) {
const y = rulerRect.y + rulerRect.height / 2;
const background = pixelAt(png, rulerRect.x + rulerRect.width - 2, y);
let left = -1;
let right = -1;
for (let x = Math.ceil(rulerRect.x); x < rulerRect.x + rulerRect.width; x += 1) {
if (!inkAt(png, x, y, background)) continue;
if (left < 0) left = x;
right = x + 1;
}
assert.ok(left >= 0, "The ruler row has no ink");
const foreground = pixelAt(png, Math.round((left + right) / 2), y);
return { left, cellWidth: (right - left) / RULER_CELLS, background, foreground };
}
function lineNames(buffer, count) {
const names = fixtureLines(0).map((line) => line.name);
const result = [];
let index = -1;
for (let row = 0; row < count; row += 1) {
if (!buffer.getLine(row).isWrapped) index += 1;
result.push(names[index] ?? `line-${index}`);
}
return result;
}
/** The row's printed cells, consecutive cells of one grapheme cluster grouped together. */
function rowClusters(line, columns, cell) {
const cells = [];
let text = "";
for (let col = 0; col < columns; col += 1) {
line.getCell(col, cell);
const width = cell.getWidth();
if (width === 0) continue;
const chars = cell.getChars() || " ";
cells.push({
col,
width,
chars,
offset: text.length,
painted: cell.isInverse() || !cell.isBgDefault(),
accent: cell.isFgRGB() && cell.getFgColor() === rgbValue(ACCENT),
});
text += chars;
}
const clusterEnds = [...graphemes.segment(text)].map(
(segment) => segment.index + segment.segment.length,
);
const clusters = [];
let end = 0;
for (const entry of cells) {
while (entry.offset >= clusterEnds[end]) end += 1;
const last = clusters.at(-1);
if (last && last.end === end) last.cells.push(entry);
else clusters.push({ end, cells: [entry] });
}
return clusters.map((cluster) => cluster.cells);
}
function rgbValue([red, green, blue]) {
return (red << 16) | (green << 8) | blue;
}
/** Each cell of a cluster that uses the accent color holds ink of its own color only. */
function checkClusterColors({ png, cluster, rect, grid }) {
const kinds = [];
for (const entry of cluster) {
const box = {
left: grid.left + entry.col * grid.cellWidth + 1,
right: grid.left + (entry.col + entry.width) * grid.cellWidth - 1,
top: rect.y,
bottom: rect.y + rect.height,
};
const [color, other] = entry.accent ? [ACCENT, grid.foreground] : [grid.foreground, ACCENT];
const own = countColor(png, box, grid.background, color, other);
const foreign = countColor(png, box, grid.background, other, color);
if (own < MIN_CLUSTER_INK || foreign > MAX_FOREIGN_INK) {
kinds.push({ col: entry.col, kind: entry.accent ? "accent-color-lost" : "color-leaked" });
}
}
return kinds;
}
function checkRows({ png, terminal, rows, grid }) {
const buffer = terminal.buffer.active;
const lastRow = buffer.cursorY;
const names = lineNames(buffer, lastRow + 1);
const failures = [];
const slots = [];
const cell = buffer.getNullCell();
for (let row = 0; row <= lastRow; row += 1) {
const rect = rows.get(row);
if (!rect) {
failures.push({ row, line: names[row], kind: "missing-row" });
continue;
}
const clusters = rowClusters(buffer.getLine(row), terminal.cols, cell);
// Color is judged on whole rows that use the accent, so a color leaking into a neighbouring
// default-colored cell fails too.
const checksColor = clusters.some((cluster) => cluster.some((entry) => entry.accent));
for (const cluster of clusters) {
const first = cluster[0];
const columns = cluster.reduce((sum, entry) => sum + entry.width, 0);
const left = grid.left + first.col * grid.cellWidth;
const right = left + columns * grid.cellWidth;
const box = { left: left + 1, right: right - 1, top: rect.y, bottom: rect.y + rect.height };
const chars = cluster.map((entry) => entry.chars).join("");
const isCursor = row === lastRow && first.col === buffer.cursorX;
let kind = null;
if (isCursor) {
const covered = countInk(png, box, grid.background);
const area = (box.right - box.left) * (box.bottom - box.top);
if (covered < area * 0.25) kind = "cursor-not-drawn";
} else if (chars.trim() !== "") {
if (countInk(png, box, grid.background) < MIN_CLUSTER_INK) kind = "missing-ink";
} else if (!cluster.some((entry) => entry.painted)) {
const quarter = (right - left) / 4;
const center = { ...box, left: left + quarter, right: right - quarter };
if (countInk(png, center, grid.background) > 0) kind = "ink-in-blank";
}
const colorFailures =
kind === null && checksColor && chars.trim() !== ""
? checkClusterColors({ png, cluster, rect, grid })
: [];
slots.push({
row,
line: names[row],
col: first.col,
width: columns,
chars,
isCursor,
box,
ok: kind === null && colorFailures.length === 0,
});
if (kind) failures.push({ row, line: names[row], col: first.col, char: chars, kind });
for (const failure of colorFailures) {
failures.push({ row, line: names[row], char: chars, ...failure });
}
}
}
return { failures, slots };
}
@@ -1,59 +0,0 @@
// Terminal output covering the scripts and styles the native renderer must place on the grid.
// Each entry is one line of output; `ruler` is drawn with full blocks so the harness can read
// the grid origin and cell width from pixels, independent of the renderer under test.
const ESC = "\u001b";
const sgr = (codes, text) => `${ESC}[${codes}m${text}${ESC}[0m`;
export const RULER_CELLS = 20;
/** A foreground no theme uses, so its pixels can be told apart from default text. */
export const ACCENT = [255, 64, 160];
const accent = (text) => sgr(`38;2;${ACCENT.join(";")}`, text);
/**
* The `select` line starts with a conjunct whose first cell the harness selects on its own. Its
* second cell uses the accent, which no theme's selection foreground matches.
*/
export const SELECT_LINE = "select";
export function fixtureLines(columns) {
const wrapSyllables = Math.ceil(columns / 2) + 6;
return [
{ name: "ruler", text: "█".repeat(RULER_CELLS) },
{ name: "ascii", text: "ASCII abcdefghij 0123456789 end" },
{ name: "hangul-mixed", text: "한글 문장 Hello end" },
{ name: "hangul-color-boundary", text: `안녕하세요${sgr(31, "NEXT")} end` },
{ name: "hangul-bold-boundary", text: `${sgr(1, "안녕하세요")}NEXT end` },
{ name: "hangul-decomposed", text: "한글 end" },
{ name: "cjk", text: "日本語テキスト 中文字符 end" },
{ name: "combining", text: "é ä ñ x̣̂ end" },
{ name: "arabic", text: "مرحبا لا 123 abc end" },
{ name: "devanagari", text: "नमस्ते क्षत्रिय end" },
{ name: "emoji", text: "👍 ❤️ 👋🏽 👩‍💻 🇰🇷 end" },
// One shaped cluster over two cells, colored per cell: the color must follow the cell.
{ name: "split-color", text: `क्${accent("ष")} ${accent("क्")}ष ल${accent("ا")} end` },
{ name: SELECT_LINE, text: `क्${accent("ष")} end` },
{ name: "box", text: "╭──┬──╮ │ab│cd│ ╰──┴──╯ ▀▄▌▐░▒▓ end" },
{
name: "styles",
text: [
sgr(1, "bold"),
sgr(3, "italic"),
sgr(4, "under"),
sgr(7, "inverse"),
sgr(42, "bg"),
sgr(9, "strike"),
"end",
].join(" "),
},
{ name: "hangul-wrap", text: "가".repeat(wrapSyllables) },
// The last line has no newline: the shell sleeps with the cursor right after it.
{ name: "cursor", text: "> 안녕하세요" },
];
}
/** The bytes `cat` writes; the tty turns each newline into CR LF. */
export function fixtureOutput(columns) {
return fixtureLines(columns)
.map((line) => line.text)
.join("\n");
}
@@ -1,423 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { PNG } from "pngjs";
import { checkTerminalScreenshot } from "./check.mjs";
// Checks that every printed cluster of a `render.mjs` run is the right glyph. `check.mjs` proves
// where ink lands and what color it has; this compares each cluster's ink with pango's (HarfBuzz)
// rendering of the same characters in the device's own fonts, so a wrong glyph, tofu, a solid
// block, a split conjunct or a cluster from elsewhere fails.
//
// FONTCONFIG_FILE=<fonts.conf> node e2e/mobile/terminal-rendering/identity.mjs <run dir> [control]
//
// The fonts.conf lists the device's font files and prefers, for `monospace`, the terminal font
// followed by the device's fallbacks; `docs/terminal-rendering.md` says how to build one. Needs
// `pango-view` on the PATH.
//
// control:
// none judge the screenshot (default); exits 1 when a cluster fails.
// rect first fill every judged cluster's box with white; exits 1 unless every cluster fails.
// swap judge each cluster against a different cluster of the corpus; exits 1 unless every
// cluster fails.
// The two controls prove the oracle refuses ink that is present but wrong.
/** Coverage grid side used to compare shapes. */
const GRID = 16;
/** A pixel at or above this ink strength (0..1) is part of a glyph's bounding box. */
const INK = 0.25;
/** Lowest shape correlation a cluster may have with its own reference. */
const THRESHOLD = 0.6;
/** How much better another cluster may match before the cluster's own match stops counting. */
const MARGIN = 0.02;
/** Lines whose cells carry decorations or a custom-drawn ruler; they stay geometry-only. */
const SKIPPED_LINES = new Set(["ruler", "styles"]);
const ZWJ = "‍";
/**
* The renderer draws box and block characters to fill the cell, so they are judged against their
* Unicode definition. Shades come from the font, whose fallback differs between the device and
* pango, so a shade is judged by how evenly and densely it inks its own bounding box. Fonts dither
* differently: ░ ▒ ▓ measure 0.16, 0.31, 0.75 in Droid Sans Mono and 0.19, 0.50, 0.81 in Menlo. The
* bands split between those, and do not overlap, so one shade never passes as another.
*/
const CELL_GLYPHS = {
"─": { arms: "LR" },
"│": { arms: "UD" },
"┬": { arms: "LRD" },
"┴": { arms: "LRU" },
"╭": { arms: "DR" },
"╮": { arms: "DL" },
"╰": { arms: "UR" },
"╯": { arms: "UL" },
"▀": { fill: "top" },
"▄": { fill: "bottom" },
"▌": { fill: "left" },
"▐": { fill: "right" },
"░": { shade: [0.08, 0.25] },
"▒": { shade: [0.25, 0.625] },
"▓": { shade: [0.625, 0.9] },
};
/** A shade must ink each region of this grid over its bounding box within its band. */
const SHADE_GRID = 4;
/** Regions of the cell, as fractions [left, top, right, bottom], that a line to each edge crosses. */
const ARM_REGIONS = {
U: [0.3, 0, 0.7, 0.2],
D: [0.3, 0.8, 0.7, 1],
L: [0, 0.3, 0.2, 0.7],
R: [0.8, 0.3, 1, 0.7],
};
/** [filled, empty] halves of each half block. */
const HALVES = {
top: [
[0, 0, 1, 0.5],
[0, 0.5, 1, 1],
],
bottom: [
[0, 0.5, 1, 1],
[0, 0, 1, 0.5],
],
left: [
[0, 0, 0.5, 1],
[0.5, 0, 1, 1],
],
right: [
[0.5, 0, 1, 1],
[0, 0, 0.5, 1],
],
};
const [dirArg, control = "none"] = process.argv.slice(2);
if (!dirArg || !["none", "rect", "swap"].includes(control)) {
throw new Error("Usage: identity.mjs <run dir> [none|rect|swap]");
}
const dir = resolve(dirArg);
const report = JSON.parse(readFileSync(resolve(dir, "report.json"), "utf8"));
const png = PNG.sync.read(readFileSync(resolve(dir, "terminal.png")));
const rows = new Map(report.rows.map((row) => [row.index, row.rect]));
const { grid, slots } = await checkTerminalScreenshot({ png, size: report.size, rows });
const printed = slots.filter(
(slot) => !SKIPPED_LINES.has(slot.line) && !slot.isCursor && slot.chars.trim() !== "",
);
/** The columns xterm gives each printed cluster. */
const columnsOf = new Map(printed.map((slot) => [slot.chars, slot.width]));
if (control === "rect") {
for (const { box } of printed) {
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
png.data.set([255, 255, 255, 255], (png.width * y + x) * 4);
}
}
}
}
/** Ink strength 0..1 of each pixel in a box: the largest channel distance from the background. */
function screenshotInk(box) {
const left = Math.ceil(box.left);
const top = Math.ceil(box.top);
const width = Math.floor(box.right) - left;
const height = Math.floor(box.bottom) - top;
const ink = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (png.width * (top + y) + left + x) * 4;
let distance = 0;
for (let channel = 0; channel < 3; channel += 1) {
distance = Math.max(
distance,
Math.abs(png.data[offset + channel] - grid.background[channel]),
);
}
ink[y * width + x] = distance / 255;
}
}
return { ink, width, height };
}
/** Ink of the slot's whole cells, scaled so the foreground color is 1. */
function cellInk(slot) {
const left = Math.round(grid.left + slot.col * grid.cellWidth);
const right = Math.round(grid.left + (slot.col + slot.width) * grid.cellWidth);
const cells = screenshotInk({ left, right, top: slot.box.top, bottom: slot.box.bottom });
const full =
Math.max(...[0, 1, 2].map((c) => Math.abs(grid.foreground[c] - grid.background[c]))) / 255;
for (let i = 0; i < cells.ink.length; i += 1) cells.ink[i] = Math.min(1, cells.ink[i] / full);
return cells;
}
const scratch = mkdtempSync(resolve(tmpdir(), "terminal-identity-"));
/** pango-view sizes its image to the text's advance plus this margin on each side. */
const REFERENCE_MARGIN = 48;
function pangoInk(text) {
const out = resolve(scratch, "reference.png");
execFileSync("pango-view", [
"--no-display",
"--dpi=72",
"--font=monospace 96px",
`--text=${text}`,
"--background=black",
"--foreground=white",
`--margin=${REFERENCE_MARGIN}`,
"-o",
out,
]);
const image = PNG.sync.read(readFileSync(out));
const ink = new Float32Array(image.width * image.height);
for (let i = 0; i < ink.length; i += 1) {
const o = i * 4;
ink[i] = Math.max(image.data[o], image.data[o + 1], image.data[o + 2]) / 255;
}
return {
ink,
width: image.width,
height: image.height,
advance: image.width - 2 * REFERENCE_MARGIN,
};
}
/** The terminal font's cell advance at the reference size. */
const referenceCell = pangoInk("M").advance;
/**
* The text as the renderer places it in `columns` cells: centred, scaled down when its advance is
* wider, and cut at the cells' edges. Glyphs whose ink overhangs their advance, such as Devanagari
* headlines, are cut on the device too.
*/
function referenceInk(text, columns) {
const image = pangoInk(text);
const box = Math.max(image.advance, columns * referenceCell);
const left = Math.round(REFERENCE_MARGIN + (image.advance - box) / 2);
const right = Math.round(REFERENCE_MARGIN + (image.advance + box) / 2);
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
if (x < left || x >= right) image.ink[y * image.width + x] = 0;
}
}
return image;
}
function regionInk({ ink, width, height }, [x0, y0, x1, y1]) {
let [sum, max, count] = [0, 0, 0];
for (let y = Math.floor(y0 * height); y < Math.ceil(y1 * height); y += 1) {
for (let x = Math.floor(x0 * width); x < Math.ceil(x1 * width); x += 1) {
const value = ink[y * width + x] ?? 0;
sum += value;
max = Math.max(max, value);
count += 1;
}
}
return { mean: sum / count, max };
}
function inkBox({ ink, width, height }) {
let [minX, minY, maxX, maxY] = [width, height, -1, -1];
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (ink[y * width + x] < INK) continue;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
return maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
}
/** Whether an unbroken line crosses the region to the cell edge: a row for L/R, a column for U/D. */
function lineCrosses({ ink, width, height }, arm) {
const [x0, y0, x1, y1] = ARM_REGIONS[arm];
const [left, top, right, bottom] = [
Math.floor(x0 * width),
Math.floor(y0 * height),
Math.ceil(x1 * width),
Math.ceil(y1 * height),
];
const horizontal = arm === "L" || arm === "R";
const [outerFrom, outerTo, innerFrom, innerTo] = horizontal
? [top, bottom, left, right]
: [left, right, top, bottom];
for (let outer = outerFrom; outer < outerTo; outer += 1) {
let unbroken = true;
for (let inner = innerFrom; inner < innerTo && unbroken; inner += 1) {
const [x, y] = horizontal ? [inner, outer] : [outer, inner];
unbroken = (ink[y * width + x] ?? 0) >= 0.5;
}
if (unbroken) return true;
}
return false;
}
/** 1 when the cell matches the character's definition, 0 otherwise. */
function cellGlyphScore(slot, spec) {
const ink = cellInk(slot);
if (spec.arms) {
const matches = Object.keys(ARM_REGIONS).every((arm) =>
spec.arms.includes(arm) ? lineCrosses(ink, arm) : regionInk(ink, ARM_REGIONS[arm]).max < 0.3,
);
return matches ? 1 : 0;
}
if (spec.fill) {
const [filled, empty] = HALVES[spec.fill].map((region) => regionInk(ink, region).mean);
return filled >= 0.8 && empty <= 0.2 ? 1 : 0;
}
const box = inkBox(ink);
if (!box || box.width < 0.7 * ink.width || box.height < 0.5 * ink.height) return 0;
const [x0, y0] = [box.x / ink.width, box.y / ink.height];
const [x1, y1] = [(box.x + box.width) / ink.width, (box.y + box.height) / ink.height];
const [w, h] = [(x1 - x0) / SHADE_GRID, (y1 - y0) / SHADE_GRID];
const regions = Array.from({ length: SHADE_GRID * SHADE_GRID }, (_, index) => {
const [gx, gy] = [index % SHADE_GRID, Math.floor(index / SHADE_GRID)];
return [x0 + gx * w, y0 + gy * h, x0 + (gx + 1) * w, y0 + (gy + 1) * h];
});
const means = regions.map((region) => regionInk(ink, region).mean);
return means.every((mean) => mean >= spec.shade[0] && mean < spec.shade[1]) ? 1 : 0;
}
/** Coverage of the ink's bounding box, centered in a square that keeps its aspect, at GRID². */
function coverage(image) {
const box = inkBox(image);
if (!box) return null;
const side = Math.max(box.width, box.height);
const originX = box.x - (side - box.width) / 2;
const originY = box.y - (side - box.height) / 2;
const vector = new Float32Array(GRID * GRID);
const step = side / GRID;
const samples = 4;
for (let gy = 0; gy < GRID; gy += 1) {
for (let gx = 0; gx < GRID; gx += 1) {
let sum = 0;
for (let sample = 0; sample < samples * samples; sample += 1) {
const x = Math.floor(originX + (gx + ((sample % samples) + 0.5) / samples) * step);
const y = Math.floor(
originY + (gy + (Math.floor(sample / samples) + 0.5) / samples) * step,
);
sum +=
x >= 0 && y >= 0 && x < image.width && y < image.height
? image.ink[y * image.width + x]
: 0;
}
vector[gy * GRID + gx] = sum / (samples * samples);
}
}
return vector;
}
/** Pearson correlation of two coverage vectors; -1 when either has no ink or no contrast. */
function correlation(a, b) {
if (!a || !b) return -1;
const n = a.length;
let [ma, mb] = [0, 0];
for (let i = 0; i < n; i += 1) {
ma += a[i];
mb += b[i];
}
ma /= n;
mb /= n;
let [num, da, db] = [0, 0, 0];
for (let i = 0; i < n; i += 1) {
num += (a[i] - ma) * (b[i] - mb);
da += (a[i] - ma) ** 2;
db += (b[i] - mb) ** 2;
}
return da === 0 || db === 0 ? -1 : num / Math.sqrt(da * db);
}
const references = new Map();
/**
* The cluster alone and with a zero-width joiner before, after and on both sides, so any Arabic
* positional form of the right letter matches.
*/
function referenceVectors(chars) {
if (!references.has(chars)) {
const forms = [chars, ZWJ + chars, chars + ZWJ, ZWJ + chars + ZWJ];
references.set(
chars,
forms.map((text) => coverage(referenceInk(text, columnsOf.get(chars)))),
);
}
return references.get(chars);
}
function shapeScore(vector, chars) {
return Math.max(...referenceVectors(chars).map((reference) => correlation(vector, reference)));
}
/**
* pango draws no ink for Apple Color Emoji, and draws flags on neither device. Those clusters have
* no reference and are reported instead of judged.
*/
const hasReference = (chars) =>
CELL_GLYPHS[chars] ||
(!/^\p{Regional_Indicator}{2}$/u.test(chars) && referenceVectors(chars).some(Boolean));
const judged = printed.filter((slot) => hasReference(slot.chars));
const unjudged = printed
.filter((slot) => !hasReference(slot.chars))
.map((slot) => ({
line: slot.line,
col: slot.col,
chars: slot.chars,
reason: "no reference ink",
}));
const distinct = [...new Set(judged.map((slot) => slot.chars))];
const shapes = distinct.filter((chars) => !CELL_GLYPHS[chars]);
/** The cluster a slot is judged against: its own, or under `swap` a different one. */
function expectedChars(slot, index) {
if (control !== "swap") return slot.chars;
const own = distinct.indexOf(slot.chars);
const other = distinct[(own + 1 + (index % (distinct.length - 1))) % distinct.length];
return other === slot.chars ? distinct[(own + 1) % distinct.length] : other;
}
function judge(slot, index) {
const expected = expectedChars(slot, index);
const result = { line: slot.line, row: slot.row, col: slot.col, chars: slot.chars, expected };
const spec = CELL_GLYPHS[expected];
if (spec) {
result.score = cellGlyphScore(slot, spec);
return result;
}
// The shape must also resemble its own cluster at least as much as any other cluster in the
// corpus, within MARGIN, so a near-lookalike such as g judged as 8 fails.
const vector = coverage(screenshotInk(slot.box));
const score = shapeScore(vector, expected);
let nearest = { chars: expected, score };
for (const other of shapes) {
if (other === expected) continue;
const otherScore = shapeScore(vector, other);
if (otherScore > nearest.score) nearest = { chars: other, score: otherScore };
}
const distinctive = nearest.chars === expected || nearest.score - score <= MARGIN;
result.score = distinctive ? score : Math.min(score, 0);
result.nearest = nearest;
return result;
}
const results = judged.map(judge);
rmSync(scratch, { recursive: true, force: true });
const passed = results.filter((result) => result.score >= THRESHOLD);
const failed = results.filter((result) => result.score < THRESHOLD);
const outDir = resolve(dir, "identity");
mkdirSync(outDir, { recursive: true });
writeFileSync(
resolve(outDir, `${control}.json`),
`${JSON.stringify({ control, threshold: THRESHOLD, unjudged, results }, null, 2)}\n`,
);
const format = (result) =>
`${result.line}@${result.col}(${result.chars}${result.expected === result.chars ? "" : `→${result.expected}`})=${result.score.toFixed(2)}${result.nearest && result.nearest.chars !== result.expected ? ` nearest ${result.nearest.chars}` : ""}`;
console.log(
`${report.label} ${control}: ${results.length} clusters judged, ${passed.length} pass, ${failed.length} fail, ${unjudged.length} unjudged (${outDir})`,
);
if (control === "none") {
for (const result of failed) console.log(` fail ${format(result)}`);
process.exitCode = failed.length === 0 ? 0 : 1;
} else {
for (const result of passed) console.log(` accepted ${format(result)}`);
process.exitCode = passed.length === 0 ? 0 : 1;
}
@@ -1,236 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
import { PNG } from "pngjs";
import { countInk, measureGrid } from "./check.mjs";
import { RULER_CELLS } from "./fixture.mjs";
// Checks ink that leaves its row: stacked marks above and below a row reach into the rows next to
// it. The script prints such rows, then in one update writes into two rows that hold another row's
// overhang and erases two rows whose overhang reaches a blank row. The overhang must be drawn
// before the update, stay drawn in the written rows, and leave no ink behind the erased rows.
//
// Preconditions are the same as `render.mjs`: the app runs the build being verified against a
// daemon on the same host as this script, and shows an idle, focused terminal with an empty prompt.
//
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/overhang.mjs
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/overhang.mjs
const platform = process.env.PLATFORM ?? "android";
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "overhang";
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
const artifacts = resolve(
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
label,
);
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
const screenshotArgs =
platform === "ios"
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
: [];
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR:
process.env.AGENT_DEVICE_STATE_DIR ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
};
mkdirSync(artifacts, { recursive: true });
/** Marks stacked below the row: Arabic dots, rings below `g`, a vowel sign below a consonant. */
const BELOW = "پپپ ييي ججج g̥̥̥̥ कृ";
/** Marks stacked above the row: accents over `e` and `s`, candrabindu, om. */
const ABOVE = "é́́́ ŝ̂̂ कँ ॐ";
/** The marks rows stay left of this column; the update writes at it, outside the checked boxes. */
const CHECKED_COLUMNS = 26;
const WRITE_COLUMN = 30;
/** 0-based rows. Each source row's overhang reaches the row next to it. */
const SOURCES = [
{ row: 2, text: BELOW, overhangRow: 3, update: "write" },
{ row: 6, text: ABOVE, overhangRow: 5, update: "erase" },
{ row: 9, text: BELOW, overhangRow: 10, update: "erase" },
{ row: 13, text: ABOVE, overhangRow: 12, update: "write" },
];
const PARK_ROW = 16;
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
env,
encoding: "utf8",
timeout: 60_000,
});
}
function deviceArgs() {
if (platform === "android") {
return [
"--platform",
"android",
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
];
}
return [
"--platform",
"ios",
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
];
}
function snapshotNodes() {
return JSON.parse(ad("snapshot", "--json")).data.nodes;
}
function pressControl(id) {
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
(candidate) => candidate.identifier === id,
);
assert.ok(node?.rect, `No ${id} control on screen`);
const { x, y, width, height } = node.rect;
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
}
async function pressEnter() {
pressControl("terminal-key-enter");
await setTimeout(2_000);
}
async function runCommand(command) {
ad("type", command, "--delay-ms", "0");
await pressEnter();
}
async function terminalSize() {
await runCommand("clear; stty size");
for (const node of snapshotNodes()) {
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
return { rows: Number(match[1]), columns: Number(match[2]) };
}
}
throw new Error("No terminal row showed the `stty size` output");
}
function shellQuote(text) {
return `'${text.replaceAll("'", "'\\''")}'`;
}
/** Prints the source rows, waits for Enter, applies the update, and waits for Enter again. */
function fixtureScript() {
const at = (row, col) => `\\033[${row + 1};${col + 1}H`;
const print = (row, text) => `printf '${at(row, 0)}%s' ${shellQuote(text)}`;
const update = SOURCES.map((source) =>
source.update === "write"
? `${at(source.overhangRow, WRITE_COLUMN)}x`
: `${at(source.row, 0)}\\033[2K`,
).join("");
return [
"stty -echo",
"printf '\\033[2J\\033[3J'",
print(0, "█".repeat(RULER_CELLS)),
...SOURCES.map((source) => print(source.row, source.text)),
`printf '${at(PARK_ROW, 0)}'`,
"read -r _",
`printf '${update}${at(PARK_ROW, 0)}'`,
"read -r _",
"stty echo",
"",
].join("\n");
}
function screenshot(name) {
const path = resolve(artifacts, name);
ad("screenshot", path, ...screenshotArgs);
return PNG.sync.read(readFileSync(path));
}
/** Pixels in `box` where the two screenshots differ by more than the ink threshold. */
function changedPixels(before, after, box) {
const difference = new PNG({ width: before.width, height: before.height });
for (let index = 0; index < before.data.length; index += 1) {
difference.data[index] = Math.abs(before.data[index] - after.data[index]);
}
return countInk(difference, box, [0, 0, 0]);
}
ad("open", appId, ...deviceArgs());
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
const hasControl = (id) => controls.some((node) => node.identifier === id);
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
const size = await terminalSize();
assert.ok(
size.rows > PARK_ROW && size.columns > WRITE_COLUMN,
`Terminal too small: ${JSON.stringify(size)}`,
);
const scriptPath = resolve(fixtureDir, "paseo-terminal-overhang.sh");
writeFileSync(scriptPath, fixtureScript());
await runCommand(`sh ${scriptPath}`);
const nodes = snapshotNodes();
const before = screenshot("before.png");
await pressEnter();
const after = screenshot("after.png");
await pressEnter();
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
const scale = before.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
const ruler = nodes.find((node) => node.identifier === "terminal-row-0")?.rect;
assert.ok(ruler, "The ruler row is not exposed as terminal-row-0");
const rulerRect = {
x: ruler.x * scale,
y: ruler.y * scale,
width: ruler.width * scale,
height: ruler.height * scale,
};
const grid = measureGrid(before, rulerRect);
const rowBox = (row) => ({
left: grid.left,
right: grid.left + CHECKED_COLUMNS * grid.cellWidth,
top: rulerRect.y + row * rulerRect.height,
bottom: rulerRect.y + (row + 1) * rulerRect.height,
});
/** Measures one source row's overhang before and after the update. */
function measure(source) {
const box = rowBox(source.overhangRow);
const overhangBefore = countInk(before, box, grid.background);
if (source.update === "write") {
const changedAfterWrite = changedPixels(before, after, box);
const failures = [
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
...(changedAfterWrite > 0 ? ["overhang-lost-after-write"] : []),
];
return { ...source, overhangBefore, changedAfterWrite, failures };
}
const inkAfterErase = countInk(after, box, grid.background);
const sourceInkAfterErase = countInk(after, rowBox(source.row), grid.background);
const failures = [
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
...(inkAfterErase > 0 ? ["overhang-left-after-erase"] : []),
...(sourceInkAfterErase > 0 ? ["source-left-after-erase"] : []),
];
return { ...source, overhangBefore, inkAfterErase, sourceInkAfterErase, failures };
}
const results = SOURCES.map(measure);
const report = { platform, label, size, grid, rulerRect, results };
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
const failures = results.flatMap((result) =>
result.failures.map((failure) => `row ${result.overhangRow}: ${failure}`),
);
console.log(`${label}: ${failures.length} failures (${artifacts})`);
for (const result of results) {
console.log(
` row ${result.row} -> ${result.overhangRow} (${result.update}): overhang ${result.overhangBefore}` +
(result.update === "write"
? `, changed after write ${result.changedAfterWrite}`
: `, left after erase ${result.inkAfterErase}, source left ${result.sourceInkAfterErase}`),
);
}
for (const failure of failures) console.log(` ${failure}`);
process.exitCode = failures.length === 0 ? 0 : 1;
@@ -1,149 +0,0 @@
import { homedir } from "node:os";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
// Reattaches to a terminal whose PTY has a different size than the app will measure, and checks
// that the client rows equal the daemon's rows. RESIZE=keyboard opens the keyboard, and the
// relaunched app starts with it closed. RESIZE=fold folds an Android foldable emulator and
// relaunches the app unfolded. Either way the shell redraws its prompt on attach.
//
// Preconditions: the app shows terminal TERMINAL_ID, idle at an empty shell prompt, against the
// daemon at PASEO_HOST.
//
// PLATFORM=android TERMINAL_ID=511fd9f0 PASEO_HOST=localhost:6870 \
// node e2e/mobile/terminal-rendering/reattach.mjs
//
// The client used to be sized after the replay arrived, so the shell's redraw landed on the
// wrong rows in most trials. TRIALS sets how many reattaches to check.
const platform = process.env.PLATFORM ?? "android";
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
const appUrl = process.env.PASEO_TERMINAL_RENDERING_APP_URL;
const terminalId = process.env.TERMINAL_ID;
const host = process.env.PASEO_HOST;
const trials = Number(process.env.TRIALS ?? 5);
const resize = process.env.RESIZE ?? "keyboard";
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "reattach";
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
const artifacts = resolve(
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
label,
);
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR:
process.env.AGENT_DEVICE_STATE_DIR ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
};
if (!terminalId || !host) throw new Error("Set TERMINAL_ID and PASEO_HOST");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
env,
encoding: "utf8",
timeout: 120_000,
});
}
function paseo(...args) {
return execFileSync("npm", ["run", "-s", "cli", "--", ...args, "--host", host], {
cwd: resolve(import.meta.dirname, "../../../../.."),
encoding: "utf8",
});
}
function pressControl(id) {
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
(candidate) => candidate.identifier === id,
);
if (!node?.rect) throw new Error(`No ${id} control on screen`);
const { x, y, width, height } = node.rect;
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
}
async function resizePty() {
if (resize === "keyboard") {
pressControl("terminal-keyboard-toggle");
await setTimeout(2_000);
return;
}
const adb = (...args) =>
execFileSync("adb", [
...(process.env.ANDROID_SERIAL ? ["-s", process.env.ANDROID_SERIAL] : []),
...args,
]);
adb("emu", "fold");
await setTimeout(5_000);
adb("shell", "am", "force-stop", appId);
adb("emu", "unfold");
await setTimeout(3_000);
}
function clientRows() {
const nodes = JSON.parse(ad("snapshot", "--json")).data.nodes;
const byIndex = new Map(nodes.map((node) => [node.index, node]));
const rows = [];
for (const node of nodes) {
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
if (!match) continue;
// agent-device leaves the label on the parent when the row inherits it.
const text = node.label ?? (node.inheritsLabel ? byIndex.get(node.parentIndex)?.label : "");
rows[Number(match[1])] = (text ?? "").trim();
}
return rows;
}
/**
* Waits for the relaunched app to show terminal rows, then for the shell's redraw to land.
* Returns null when no row appears, for example when the app crashed on launch.
*/
async function attachedClientRows() {
for (let attempt = 0; attempt < 30; attempt += 1) {
await setTimeout(2_000);
if (clientRows().some((text) => text !== "")) {
await setTimeout(3_000);
return clientRows();
}
}
return null;
}
function mismatches(client, server) {
const rows = [];
for (let row = 0; row < Math.max(client.length, server.length); row += 1) {
const expected = (server[row] ?? "").trim();
const actual = client[row] ?? "";
if (expected !== actual) rows.push({ row, server: expected, client: actual });
}
return rows;
}
const results = [];
for (let trial = 1; trial <= trials; trial += 1) {
paseo("terminal", "send-keys", terminalId, "clear", "Enter");
await setTimeout(1_500);
await resizePty();
ad("open", appId, ...(appUrl ? [appUrl] : []), "--relaunch");
const client = await attachedClientRows();
ad("screenshot", resolve(artifacts, `trial-${trial}.png`));
if (!client) {
results.push({ trial, attached: false });
console.log(`${label} trial ${trial}: no terminal rows appeared`);
continue;
}
const server = paseo("terminal", "capture", terminalId).split("\n");
const rows = mismatches(client, server);
results.push({ trial, attached: true, mismatchedRows: rows });
console.log(`${label} trial ${trial}: ${rows.length} mismatched rows`);
}
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(results, null, 2)}\n`);
const attached = results.filter((result) => result.attached);
const failed = attached.filter((result) => result.mismatchedRows.length > 0).length;
console.log(
`${label}: ${failed} of ${attached.length} reattaches differ from the daemon, ${trials - attached.length} never attached (${artifacts})`,
);
process.exitCode = failed === 0 && attached.length === trials ? 0 : 1;
@@ -1,197 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
import { PNG } from "pngjs";
import {
checkPartialSelection,
checkTerminalScreenshot,
selectionTarget,
writeOverlay,
} from "./check.mjs";
import { fixtureOutput } from "./fixture.mjs";
// Renders the fixture in a real Paseo terminal and checks the screenshot with `check.mjs`, then
// selects one cell of a conjunct and checks a second screenshot.
//
// Preconditions: the app runs the build being verified against a daemon on the same host as
// this script, and shows an idle, focused terminal with an empty shell prompt.
//
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
const platform = process.env.PLATFORM ?? "android";
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "run";
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
const artifacts = resolve(
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
label,
);
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
// iOS simulators screenshot at one pixel per point unless asked; glyph identity needs the
// device's real density. Android screenshots are always in device pixels.
const screenshotArgs =
platform === "ios"
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
: [];
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR:
process.env.AGENT_DEVICE_STATE_DIR ??
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
};
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
env,
encoding: "utf8",
timeout: 60_000,
});
}
function deviceArgs() {
if (platform === "android") {
return [
"--platform",
"android",
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
];
}
return [
"--platform",
"ios",
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
];
}
function snapshotNodes() {
return JSON.parse(ad("snapshot", "--json")).data.nodes;
}
function rowNodes(nodes) {
const rows = new Map();
for (const node of nodes) {
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
if (match && node.rect) rows.set(Number(match[1]), node);
}
return rows;
}
/**
* Taps a control at its center. On iOS the keyboard toolbar is a full-screen accessibility node
* listed after the key bar, so pressing by selector reports the keys as covered.
*/
function pressControl(id) {
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
(candidate) => candidate.identifier === id,
);
assert.ok(node?.rect, `No ${id} control on screen`);
const { x, y, width, height } = node.rect;
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
}
async function runCommand(command) {
ad("type", command, "--delay-ms", "0");
pressControl("terminal-key-enter");
await setTimeout(2_000);
}
async function terminalSize() {
await runCommand("clear; stty size");
for (const node of snapshotNodes()) {
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
return { rows: Number(match[1]), columns: Number(match[2]) };
}
}
throw new Error("No terminal row showed the `stty size` output");
}
ad("open", appId, ...deviceArgs());
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
const hasControl = (id) => controls.some((node) => node.identifier === id);
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
const size = await terminalSize();
const fixturePath = resolve(fixtureDir, "paseo-terminal-rendering.txt");
const scriptPath = resolve(fixtureDir, "paseo-terminal-rendering.sh");
writeFileSync(fixturePath, fixtureOutput(size.columns));
writeFileSync(scriptPath, `printf '\\033[2J\\033[3J\\033[H'; cat '${fixturePath}'; sleep 120\n`);
await runCommand(`sh ${scriptPath}`);
const nodes = snapshotNodes();
const screenshotPath = resolve(artifacts, "terminal.png");
ad("screenshot", screenshotPath, ...screenshotArgs);
const png = PNG.sync.read(readFileSync(screenshotPath));
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
const scale = png.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
const toPixels = ({ x, y, width, height }) => ({
x: x * scale,
y: y * scale,
width: width * scale,
height: height * scale,
});
const rows = rowNodes(nodes);
const rects = new Map([...rows].map(([index, node]) => [index, toPixels(node.rect)]));
const { grid, failures, slots } = await checkTerminalScreenshot({ png, size, rows: rects });
// Long-press selects the conjunct as a word; dragging back from its second cell leaves the first.
const target = await selectionTarget({ size });
const targetRect = rects.get(target.row);
assert.ok(targetRect, "The select line is not on screen");
const y = Math.round((targetRect.y + targetRect.height / 2) / scale);
const cellX = (offset) => Math.round((grid.left + (target.col + offset) * grid.cellWidth) / scale);
ad("longpress", String(cellX(0.5)), String(y), "800");
ad("gesture", "pan", String(cellX(1.75)), String(y), String(cellX(0.5) - cellX(1.75)), "0", "600");
const selectionPath = resolve(artifacts, "selection.png");
ad("screenshot", selectionPath, ...screenshotArgs);
const selectionFailures = checkPartialSelection({
png: PNG.sync.read(readFileSync(selectionPath)),
rows: rects,
grid,
target,
});
for (const failure of selectionFailures) failures.push({ line: "select", ...failure });
// A tap clears the selection; it would otherwise survive into the next run's screenshot.
const lastRect = rects.get(size.rows - 1);
if (lastRect) {
ad("press", String(cellX(4)), String(Math.round((lastRect.y + lastRect.height / 2) / scale)));
}
try {
pressControl("terminal-key-ctrl");
ad("type", "c", "--delay-ms", "0");
} catch (error) {
console.warn(`Could not interrupt the fixture: ${error.message}`);
}
writeOverlay(png, slots, resolve(artifacts, "overlay.png"));
const report = {
platform,
label,
size,
grid,
checkedClusters: slots.length,
failures,
rows: [...rows.keys()].map((index) => ({
index,
label: rows.get(index).label,
rect: rects.get(index),
})),
};
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
const byLine = Map.groupBy(failures, (failure) => failure.line);
console.log(
`${label}: ${slots.length} clusters checked, ${failures.length} failures (${artifacts})`,
);
for (const [line, list] of byLine) {
console.log(
` ${line}: ${list.map((failure) => `${failure.kind}@${failure.col ?? "-"}${failure.char ? `(${failure.char})` : ""}`).join(" ")}`,
);
}
process.exitCode = failures.length === 0 ? 0 : 1;
+2 -13
View File
@@ -107,8 +107,6 @@ const daemonTest = metroTest.extend<
});
const test = daemonTest.extend<{
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
e2eOnboardingCompleted: boolean;
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
@@ -116,9 +114,8 @@ const test = daemonTest.extend<{
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
e2eOnboardingCompleted: [true, { option: true }],
paseoE2ESetup: [
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -160,7 +157,7 @@ const test = daemonTest.extend<{
const createAgentPreferences = buildCreateAgentPreferences();
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -188,20 +185,12 @@ const test = daemonTest.extend<{
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
// The shared daemon often has no projects, which would open first-run setup over the app.
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
localStorage.setItem(
"@paseo:onboarding",
JSON.stringify({ completedAt: new Date(0).toISOString() }),
);
}
},
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
onboardingCompleted: e2eOnboardingCompleted,
},
);
@@ -1,5 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { writeFile, readFile, readdir } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
@@ -154,42 +154,3 @@ export async function plantUnclosableTerminalProgram(
throw error;
}
}
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
export function suspendDaemonProcess(pid: number): void {
process.kill(pid, "SIGSTOP");
}
export function resumeDaemonProcess(pid: number): void {
try {
process.kill(pid, "SIGCONT");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
try {
const state = await psField(pid, "stat");
return state.length > 0 && !state.startsWith("Z");
} catch (error) {
if ((error as { code?: unknown }).code === 1) return false;
throw error;
}
}
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
const directory = path.join(daemon.paseoHome, "terminal-worker");
const records = await Promise.all(
(await readdir(directory))
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ pid: number }>;
};
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
}),
);
return records.flat();
}
@@ -1,11 +1,4 @@
import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -13,8 +6,6 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon {
serverId: string;
port: number;
@@ -25,8 +16,6 @@ export interface IsolatedHostDaemon {
}
export interface IsolatedHostDaemonOptions {
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
environment?: NodeJS.ProcessEnv;
mutableRelay?: {
enabled: boolean;
@@ -95,28 +84,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
function withMutableRelay(
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
): NonNullable<IsolatedHostDaemonOptions["config"]> {
const endpoint =
relay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
return {
...config,
daemon: {
...config.daemon,
relay: {
enabled: relay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
};
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -162,36 +129,29 @@ export async function startIsolatedHostDaemon(
throw error;
}
}
const config = options.mutableRelay
? withMutableRelay(options.config ?? {}, options.mutableRelay)
: options.config;
if (config) {
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ version: 1, ...config })}\n`,
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
);
}
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
let child: ChildProcess | null = null;
const stopDaemon = async (): Promise<void> => {
// The CLI owns graceful shutdown, forced cleanup and detached terminal
// reaping. Keep its ESM graph out of Playwright's transforming loader.
await execFileAsync(
process.execPath,
[
path.resolve(__dirname, "../../../../cli/dist/index.js"),
"daemon",
"stop",
"--home",
paseoHome,
"--force",
],
{ timeout: 30_000 },
);
await killProcessTree(child);
};
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -209,21 +169,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
const spawned = publishedPackageRoot
const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = "";
spawned.stderr?.on("data", (chunk: Buffer) => {
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, spawned);
return spawned;
await waitForServer(port, child);
return child;
} catch (error) {
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -231,10 +191,10 @@ export async function startIsolatedHostDaemon(
}
};
let child: ChildProcess;
try {
await spawnDaemon();
child = await spawnDaemon();
} catch (error) {
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
@@ -249,22 +209,22 @@ export async function startIsolatedHostDaemon(
serverId,
port,
paseoHome,
getPid: () => child?.pid,
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopDaemon();
await spawnDaemon();
await killProcessTree(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
await stopDaemon();
closed = true;
await killProcessTree(child);
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
closed = true;
},
};
}
@@ -1,192 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
export async function openAppAsNewUser(
page: Page,
host: { serverId: string; port: number },
): Promise<void> {
const seededHost = buildSeededHost({
serverId: host.serverId,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ registryHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ registryHost: seededHost, preferences: buildCreateAgentPreferences() },
);
await page.goto("/");
}
export function setupModal(page: Page): Locator {
return page.getByRole("dialog").filter({ hasText: "Set up Paseo" });
}
export async function expectSetupStep(page: Page, title: string): Promise<void> {
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
});
}
export async function pickTheme(page: Page, name: string): Promise<void> {
const tile = setupModal(page).getByRole("radio", { name, exact: true });
await tile.click();
await expect(tile).toBeChecked();
}
export async function continueSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
}
export async function skipSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click();
await expect(setupModal(page)).toHaveCount(0);
}
/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */
export async function addProjectFromSetup(page: Page, projectPath: string): Promise<void> {
await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(projectPath);
await page.keyboard.press("Enter");
await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function showSetupAgainFromSettings(page: Page): Promise<void> {
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await page.getByRole("button", { name: "Show setup again", exact: true }).click();
}
export async function goToSetupStep(page: Page, label: string): Promise<void> {
await setupModal(page).getByRole("tab", { name: label, exact: true }).click();
}
export function paseoToolsSwitch(scope: Page | Locator): Locator {
return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true });
}
export async function finishSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
await expect(setupModal(page)).toHaveCount(0);
}
/** Clicks the Tailscale docs link in the Phone step and returns the page it opened. */
export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
await page
.context()
.route("https://paseo.sh/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
);
const popup = page.waitForEvent("popup");
await setupModal(page).getByRole("link", { name: "Read how to connect over Tailscale" }).click();
const docs = await popup;
await docs.waitForLoadState("domcontentloaded");
const url = docs.url();
await docs.close();
return url;
}
export interface SetupHost {
serverId: string;
port: number;
close(): Promise<void>;
}
/**
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
* never depend on the machine running the test. `withBrokenProvider` adds a provider that cannot
* start.
*/
export async function startSetupHost(
label: string,
options: {
claudeSessionsIn?: readonly string[];
withBrokenProvider?: boolean;
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
} = {},
): Promise<SetupHost> {
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
const daemon = await startIsolatedHostDaemon(serverId, {
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
environment: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfigDir },
mutableRelay: options.mutableRelay,
});
return {
serverId,
port: daemon.port,
close: async () => {
await daemon.close();
await rm(claudeConfigDir, { recursive: true, force: true });
},
};
}
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
const newest = Date.now() - 60_000;
for (const [index, cwd] of cwds.entries()) {
const sessionId = `onboarding-session-${index + 1}`;
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
await mkdir(projectDir, { recursive: true });
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
const record = {
type: "user",
uuid: `${sessionId}-user`,
message: { role: "user", content: `Work on session ${index + 1}` },
cwd,
sessionId,
};
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return configDir;
}
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
const brokenProvider = {
"broken-acp": {
extends: "acp",
label: "Broken ACP",
command: ["missing-agent-command", "acp"],
},
};
return {
agents: {
providers: {
codex: { enabled: false },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
...(options.withBrokenProvider ? brokenProvider : {}),
},
},
};
}
export function projectSuggestion(page: Page, name: string): Locator {
return setupModal(page).getByRole("checkbox", { name, exact: true });
}
/** The footer's main button, which adds the checked suggestions when there are any. */
export function setupPrimaryButton(page: Page, label: string): Locator {
return setupModal(page).getByRole("button", { name: label, exact: true });
}
@@ -28,10 +28,6 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id";
@@ -207,100 +206,3 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
export async function expectTerminalCursor(
page: Page,
expected: { shape: "bar" | "underline"; blinking: boolean },
): Promise<void> {
const observed = await page.evaluate(async () => {
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
if (!terminal) throw new Error("Terminal did not mount");
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
(canvas): canvas is HTMLCanvasElement =>
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
);
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
const copy = document.createElement("canvas");
const context = copy.getContext("2d");
if (!context) throw new Error("Cursor pixel capture is unavailable");
terminal.focus();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
if (!terminal.element?.contains(document.activeElement))
throw new Error("Terminal did not focus");
const started = performance.now();
const frames: Array<{ time: number; width: number; height: number }> = [];
const rendered = terminal.onRender(() => {
copy.width = source.width;
copy.height = source.height;
// WebGL's default drawing buffer must be copied before this render returns.
context.drawImage(source, 0, 0);
const cellWidth = source.width / terminal.cols;
const cellHeight = source.height / terminal.rows;
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
const width = Math.ceil(cellWidth);
const height = Math.ceil(cellHeight);
const background = context.getImageData(x + width + 2, y, 1, 1).data;
const pixels = context.getImageData(x, y, width, height).data;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const offset = (row * width + col) * 4;
if (
![0, 1, 2, 3].some(
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
)
)
continue;
minX = Math.min(minX, col);
maxX = Math.max(maxX, col);
minY = Math.min(minY, row);
maxY = Math.max(maxY, row);
}
}
frames.push({
time: performance.now() - started,
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
});
});
try {
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
try {
await new Promise((resolve) => setTimeout(resolve, 1800));
} finally {
clearInterval(refresh);
}
} finally {
rendered.dispose();
}
return {
frames,
focused: terminal.element?.contains(document.activeElement),
samples: frames.length,
width: Math.max(...frames.map((frame) => frame.width)),
height: Math.max(...frames.map((frame) => frame.height)),
blinking:
frames.filter(
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
).length >= 2,
};
});
expect(observed.samples).toBeGreaterThan(5);
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
if (expected.shape === "bar") {
expect(observed.width).toBeGreaterThan(0);
expect(observed.width).toBeLessThan(0.4);
expect(observed.height).toBeGreaterThan(0.6);
} else {
expect(observed.width).toBeGreaterThan(0.6);
expect(observed.height).toBeGreaterThan(0);
expect(observed.height).toBeLessThan(0.4);
}
}
@@ -25,7 +25,6 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -64,7 +63,6 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -88,7 +86,6 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -232,12 +229,7 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
@@ -1,25 +0,0 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.terminal'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.terminal"
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation "androidx.customview:customview:1.1.0"
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test:runner:1.6.2"
androidTestImplementation "androidx.test.ext:junit:1.2.1"
}
@@ -1,38 +0,0 @@
[
{
"output": "स्ते",
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्ष",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "নক্ষত্র",
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 3, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्अ",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्अ ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "ক্অ",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;ক্অ ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "अ्क",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
}
]
@@ -1,140 +0,0 @@
package sh.paseo.terminal
import android.os.Build
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
private const val COLUMNS = 8
/** Rasters that draw the same clusters match exactly; a wrong clustering differs by 0.12 or more. */
private const val MATCH_TOLERANCE = 0.01f
@RunWith(AndroidJUnit4::class)
class TerminalRowLayoutTest {
private val font = TerminalFont("monospace", 40f, 1f)
private val paints = TerminalPaints()
/**
* Draws each row of `grapheme-rows.json` and compares it with its grapheme clusters drawn one at a
* time, each centred in its own columns. `terminal-frame.test.ts` checks that the rows are what
* xterm produces and that the clusters are UAX #29 grapheme clusters.
*/
@Test
fun drawsEachGraphemeClusterInItsOwnColumns() {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
val rows = JSONArray(assets.open("grapheme-rows.json").bufferedReader().readText())
for (index in 0 until rows.length()) {
val row = rows.getJSONObject(index)
val source = TerminalRowSource.decode(row.getString("row"))
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
// Selection covers row 0 only, so drawing as row 1 draws nothing selected.
val drawn = raster { layout.drawSelected(it, 1, TerminalSelection(TerminalSelectionRecord()), paints) }
val expected = raster { drawClusters(it, source, starts(row, "clusterStarts")) }
val contrast = raster { drawClusters(it, source, starts(row, "contrastStarts")) }
val output = row.getString("output")
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
continue
}
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
private fun raster(draw: (Canvas) -> Unit): Bitmap {
val bitmap = Bitmap.createBitmap(COLUMNS * font.cellWidthPx, font.cellHeightPx, Bitmap.Config.ARGB_8888)
draw(Canvas(bitmap))
return bitmap
}
/** Draws each cluster as its own text, centred in its columns and scaled down when wider. */
private fun drawClusters(canvas: Canvas, source: TerminalRowSource, starts: List<Int>) {
val cellCount = source.cells.size / 2
val paint = Paint(font.paint(0)).apply { color = source.styles[1].toLong().toInt() }
val offsets = IntArray(cellCount + 1)
val cols = IntArray(cellCount + 1)
for (cell in 0 until cellCount) {
offsets[cell + 1] = offsets[cell] + source.cells[cell * 2]
cols[cell + 1] = cols[cell] + source.cells[cell * 2 + 1]
}
for ((index, first) in starts.withIndex()) {
val end = starts.getOrElse(index + 1) { cellCount }
val text = source.text.substring(offsets[first], offsets[end])
if (text.isBlank()) continue
val slot = (cols[end] - cols[first]) * font.cellWidthPx.toFloat()
val advance = paint.measureText(text)
val x = cols[first] * font.cellWidthPx + if (advance > slot + 1f) 0f else (slot - advance) / 2f
canvas.save()
if (advance > slot + 1f) canvas.scale(slot / advance, slot / advance, x, font.cellHeightPx / 2f)
canvas.drawText(text, x, font.baselinePx.toFloat(), paint)
canvas.restore()
}
}
/** Share of inked pixels, in either image, whose coverage differs by more than a quarter. */
private fun difference(a: Bitmap, b: Bitmap): Float {
var inked = 0
var differing = 0
for (y in 0 until a.height) {
for (x in 0 until a.width) {
val (left, right) = (a.getPixel(x, y) ushr 24) to (b.getPixel(x, y) ushr 24)
if (left < 64 && right < 64) continue
inked += 1
if (kotlin.math.abs(left - right) > 64) differing += 1
}
}
return if (inked == 0) 0f else differing.toFloat() / inked
}
}
@@ -1 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -1,24 +0,0 @@
package sh.paseo.terminal
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoTerminalModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoTerminal")
View(PaseoTerminalView::class) {
Events("onCellMetrics")
Prop("frame") { view: PaseoTerminalView, frame: TerminalFrameRecord -> view.setFrame(frame) }
Prop("theme") { view: PaseoTerminalView, theme: TerminalThemeRecord -> view.setTheme(theme) }
Prop("fontFamily") { view: PaseoTerminalView, family: String -> view.setFontFamily(family) }
Prop("fontSize") { view: PaseoTerminalView, size: Double -> view.setFontSize(size) }
Prop("customGlyphs") { view: PaseoTerminalView, glyphs: List<TerminalCustomGlyphRecord> ->
view.setCustomGlyphs(glyphs)
}
OnViewDidUpdateProps { view: PaseoTerminalView -> view.commit() }
}
}
}
@@ -1,195 +0,0 @@
package sh.paseo.terminal
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import android.os.Bundle
import android.view.MotionEvent
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.customview.widget.ExploreByTouchHelper
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
private const val MIN_CACHED_ROWS = 256
private const val CURSOR_ALPHA = 0.45f
/**
* Paints terminal frames on the cell grid. Row shaping is cached by row content, so cursor and
* selection changes repaint without reshaping, and unchanged rows replay their display lists.
*/
@SuppressLint("ViewConstructor")
class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val onCellMetrics by EventDispatcher<Map<String, Float>>()
private var fontFamily = "monospace"
private var fontSize = 12.0
private var font: TerminalFont? = null
private var customGlyphs = TerminalCustomGlyphs.EMPTY
private var frame: TerminalFrameRecord? = null
private var frameChanged = false
private var rows: List<TerminalRowLayout> = emptyList()
private var columns = 0
private var cursor: TerminalCursorRecord? = null
private var selection: TerminalSelection? = null
private var backgroundColor = Color.BLACK
private var cursorColor = Color.WHITE
private val paints = TerminalPaints()
private val rowCache = object : LinkedHashMap<String, TerminalRowLayout>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, TerminalRowLayout>): Boolean {
if (size <= maxOf(MIN_CACHED_ROWS, rows.size * 2)) return false
eldest.value.release()
return true
}
}
private val accessibility = RowAccessibility()
init {
setWillNotDraw(false)
ViewCompat.setAccessibilityDelegate(this, accessibility)
}
fun setFrame(value: TerminalFrameRecord) {
frame = value
frameChanged = true
}
fun setTheme(value: TerminalThemeRecord) {
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
invalidate()
}
fun setFontFamily(value: String) {
fontFamily = value
}
fun setFontSize(value: Double) {
fontSize = value
}
fun setCustomGlyphs(value: List<TerminalCustomGlyphRecord>) {
customGlyphs = TerminalCustomGlyphs(value)
clearRowCache()
frameChanged = true
}
/** Applies a batch of prop updates: font first, then the frame laid out with it. */
fun commit() {
val density = resources.displayMetrics.density
val sizePx = (fontSize * density).toFloat()
val current = font
if (current == null || !current.sameAs(fontFamily, sizePx, density)) {
val next = TerminalFont(fontFamily, sizePx, density)
font = next
clearRowCache()
frameChanged = true
onCellMetrics(
mapOf(
"cellWidth" to next.cellWidthPx / density,
"cellHeight" to next.cellHeightPx / density,
),
)
}
if (frameChanged) {
frameChanged = false
layoutFrame()
}
invalidate()
}
private fun layoutFrame() {
val currentFont = font ?: return
val record = frame ?: return
columns = record.columns
cursor = record.cursor
selection = record.selection?.let(::TerminalSelection)
rows = record.rows.map { encoded ->
rowCache.getOrPut(encoded) {
TerminalRowLayout(TerminalRowSource.decode(encoded), currentFont, customGlyphs)
}
}
accessibility.invalidateRoot()
}
private fun clearRowCache() {
for (layout in rowCache.values) layout.release()
rowCache.clear()
rows = emptyList()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(backgroundColor)
val currentFont = font ?: return
val cellWidth = currentFont.cellWidthPx
val cellHeight = currentFont.cellHeightPx
val gridWidth = columns * cellWidth
canvas.save()
canvas.clipRect(0, 0, minOf(width, gridWidth), height)
val activeSelection = selection
for ((index, row) in rows.withIndex()) {
canvas.save()
canvas.translate(0f, (index * cellHeight).toFloat())
if (activeSelection != null && activeSelection.intersectsRow(index)) {
row.drawSelected(canvas, index, activeSelection, paints)
} else {
row.draw(canvas, gridWidth, paints)
}
canvas.restore()
}
cursor?.let { position ->
paints.fill.color = withAlpha(cursorColor, CURSOR_ALPHA)
val left = (position.col * cellWidth).toFloat()
val top = (position.row * cellHeight).toFloat()
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paints.fill)
}
canvas.restore()
}
override fun dispatchHoverEvent(event: MotionEvent): Boolean =
accessibility.dispatchHoverEvent(event) || super.dispatchHoverEvent(event)
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
for (layout in rowCache.values) layout.release()
}
private fun withAlpha(color: Int, alpha: Float): Int =
Color.argb((Color.alpha(color) * alpha).toInt(), Color.red(color), Color.green(color), Color.blue(color))
/** Exposes each non-blank row as a virtual text node, `terminal-row-<index>`. */
private inner class RowAccessibility : ExploreByTouchHelper(this@PaseoTerminalView) {
override fun getVirtualViewAt(x: Float, y: Float): Int {
val cellHeight = font?.cellHeightPx ?: return INVALID_ID
val row = (y / cellHeight).toInt()
return if (row in rows.indices && rows[row].describe().isNotEmpty()) row else INVALID_ID
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
for ((index, row) in rows.withIndex()) {
if (row.describe().isNotEmpty()) virtualViewIds.add(index)
}
}
@Suppress("DEPRECATION")
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
val cellHeight = font?.cellHeightPx ?: 1
val label = rows.getOrNull(virtualViewId)?.describe().orEmpty()
node.className = TextView::class.java.name
node.text = label
node.contentDescription = label
node.viewIdResourceName = "terminal-row-$virtualViewId"
val top = virtualViewId * cellHeight
node.setBoundsInParent(Rect(0, top, minOf(width, columns * (font?.cellWidthPx ?: 1)), top + cellHeight))
}
override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean = false
}
}
@@ -1,76 +0,0 @@
package sh.paseo.terminal
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
/**
* Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and
* blocks join across cells regardless of the font's own metrics.
*/
internal class TerminalCustomGlyph(record: TerminalCustomGlyphRecord) {
private val rects: FloatArray = FloatArray(record.rects.size) { record.rects[it].toFloat() }
private val commands: DoubleArray = record.path
private val strokeWidth: Float = record.strokeWidth.toFloat()
private var scaledPath: Path? = null
private var scaledWidth = 0f
private var scaledHeight = 0f
fun draw(canvas: Canvas, x: Float, width: Float, height: Float, density: Float, paint: Paint) {
paint.style = Paint.Style.FILL
var index = 0
while (index + 3 < rects.size) {
canvas.drawRect(
x + rects[index] * width,
rects[index + 1] * height,
x + (rects[index] + rects[index + 2]) * width,
(rects[index + 1] + rects[index + 3]) * height,
paint,
)
index += 4
}
if (commands.isEmpty()) return
val path = pathFor(width, height)
paint.style = Paint.Style.STROKE
paint.strokeWidth = strokeWidth * density
paint.strokeCap = Paint.Cap.BUTT
paint.strokeJoin = Paint.Join.MITER
canvas.save()
canvas.translate(x, 0f)
canvas.drawPath(path, paint)
canvas.restore()
paint.style = Paint.Style.FILL
}
private fun pathFor(width: Float, height: Float): Path {
val cached = scaledPath
if (cached != null && scaledWidth == width && scaledHeight == height) return cached
val path = Path()
var index = 0
while (index < commands.size) {
val x = { offset: Int -> commands[index + offset].toFloat() * width }
val y = { offset: Int -> commands[index + offset].toFloat() * height }
when (commands[index].toInt()) {
0 -> { path.moveTo(x(1), y(2)); index += 3 }
1 -> { path.lineTo(x(1), y(2)); index += 3 }
2 -> { path.quadTo(x(1), y(2), x(3), y(4)); index += 5 }
else -> index = commands.size
}
}
scaledPath = path
scaledWidth = width
scaledHeight = height
return path
}
}
internal class TerminalCustomGlyphs(records: List<TerminalCustomGlyphRecord>) {
private val glyphs: Map<String, TerminalCustomGlyph> =
records.associate { it.char to TerminalCustomGlyph(it) }
operator fun get(text: String): TerminalCustomGlyph? = glyphs[text]
companion object {
val EMPTY = TerminalCustomGlyphs(emptyList())
}
}
@@ -1,61 +0,0 @@
package sh.paseo.terminal
import android.graphics.Paint
import android.graphics.Typeface
import kotlin.math.max
import kotlin.math.roundToInt
private const val MEASURE_TEXT = "mmmmmmmmmm"
/**
* The terminal font at one pixel size: a paint per face (regular, bold, italic, bold italic) and
* the cell those faces are laid out on. Cells are whole device pixels so every column origin is
* exact. Platform fallback fonts cover characters the family lacks.
*/
internal class TerminalFont(val family: String, val sizePx: Float, val density: Float) {
private val faces: Array<Paint> = Array(4) { face ->
Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
typeface = Typeface.create(family, typefaceStyle(face))
textSize = sizePx
}
}
val cellWidthPx: Int
val cellHeightPx: Int
val baselinePx: Int
val underlineOffsetPx: Float
val underlineThicknessPx: Float
val strikeOffsetPx: Float
val strikeThicknessPx: Float
init {
val regular = faces[0]
cellWidthPx = max(1, (regular.measureText(MEASURE_TEXT) / MEASURE_TEXT.length).roundToInt())
val metrics = regular.fontMetricsInt
cellHeightPx = max(1, metrics.descent - metrics.ascent)
baselinePx = -metrics.ascent
underlineOffsetPx = regular.underlinePosition
underlineThicknessPx = max(1f, regular.underlineThickness)
strikeOffsetPx = regular.strikeThruPosition
strikeThicknessPx = max(1f, regular.strikeThruThickness)
}
/** Paint for a style's face; callers only change its color. */
fun paint(flags: Int): Paint = faces[face(flags)]
fun sameAs(family: String, sizePx: Float, density: Float): Boolean =
this.family == family && this.sizePx == sizePx && this.density == density
companion object {
fun face(flags: Int): Int =
(if (flags and TerminalStyleFlags.BOLD != 0) 1 else 0) +
(if (flags and TerminalStyleFlags.ITALIC != 0) 2 else 0)
private fun typefaceStyle(face: Int): Int = when (face) {
1 -> Typeface.BOLD
2 -> Typeface.ITALIC
3 -> Typeface.BOLD_ITALIC
else -> Typeface.NORMAL
}
}
}
@@ -1,99 +0,0 @@
package sh.paseo.terminal
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
class TerminalCursorRecord : Record {
@Field val row: Int = 0
@Field val col: Int = 0
}
class TerminalSelectionRecord : Record {
@Field val startRow: Int = 0
@Field val startCol: Int = 0
@Field val endRow: Int = 0
@Field val endCol: Int = 0
}
class TerminalFrameRecord : Record {
@Field val columns: Int = 0
/** Encoded rows, `cells;styles;text`; see `frame-encoding.ts`. */
@Field val rows: List<String> = emptyList()
@Field val cursor: TerminalCursorRecord? = null
@Field val selection: TerminalSelectionRecord? = null
}
class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@Field val char: String = ""
@Field val rects: DoubleArray = DoubleArray(0)
@Field val path: DoubleArray = DoubleArray(0)
@Field val strokeWidth: Double = 0.0
}
internal object TerminalStyleFlags {
const val BOLD = 1
const val ITALIC = 2
const val UNDERLINE = 4
const val STRIKETHROUGH = 8
}
/** JS sends colors as unsigned ARGB numbers; Android paints signed ARGB ints. */
internal fun argb(value: Double): Int = value.toLong().toInt()
/** One row as the caller described it: cell text, column spans and style spans. */
internal class TerminalRowSource private constructor(
val text: String,
val cells: IntArray,
val styles: DoubleArray,
) {
companion object {
/** Decodes `cells;styles;text`, where both number lists are comma separated. */
fun decode(encoded: String): TerminalRowSource {
val cellsEnd = encoded.indexOf(';')
val stylesEnd = encoded.indexOf(';', cellsEnd + 1)
val cells = parseNumbers(encoded, 0, cellsEnd)
val styles = parseNumbers(encoded, cellsEnd + 1, stylesEnd)
return TerminalRowSource(
encoded.substring(stylesEnd + 1),
IntArray(cells.size) { cells[it].toInt() },
DoubleArray(styles.size) { styles[it].toDouble() },
)
}
private fun parseNumbers(encoded: String, start: Int, end: Int): LongArray {
if (end <= start) return LongArray(0)
var count = 1
for (index in start until end) if (encoded[index] == ',') count += 1
val numbers = LongArray(count)
var slot = 0
for (index in start until end) {
val char = encoded[index]
if (char == ',') slot += 1 else numbers[slot] = numbers[slot] * 10 + (char - '0')
}
return numbers
}
}
}
internal class TerminalSelection(record: TerminalSelectionRecord) {
private val startRow = record.startRow
private val startCol = record.startCol
private val endRow = record.endRow
private val endCol = record.endCol
fun intersectsRow(row: Int): Boolean = row in startRow..endRow
/** A cell is selected when any column it covers is inside the inclusive range. */
fun contains(row: Int, col: Int, span: Int): Boolean {
if (!intersectsRow(row)) return false
val firstCol = if (row == startRow) startCol else 0
val lastCol = if (row == endRow) endCol else Int.MAX_VALUE
return col + span - 1 >= firstCol && col <= lastCol
}
}
@@ -1,302 +0,0 @@
package sh.paseo.terminal
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RenderNode
import android.graphics.text.MeasuredText
import android.icu.text.BreakIterator
/** Shaped glyphs may exceed their slot by this many pixels before they are scaled down. */
private const val OVERFLOW_TOLERANCE_PX = 1f
/** Stands in for an open edge when a group is clipped on one side only. */
private const val CLIP_UNBOUNDED_PX = 100_000f
/**
* One row shaped once and placed on the terminal grid.
*
* Each run of cells with the same font face is shaped as a whole, so contextual forms, ligatures,
* marks and fallback fonts see their neighbours. The shaper reports a cluster's advance on its
* first UTF-16 unit and zero on the units it absorbed. A cell whose first unit has zero advance
* therefore belongs to the cluster of the cell before it: a ligature, conjunct, emoji sequence or
* reordered mark that spans columns. A cell that does not start a grapheme cluster also stays with
* the cell before it: a half-form conjunct such as स्त keeps both consonants' advances, and drawing
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
private val font: TerminalFont,
private val customGlyphs: TerminalCustomGlyphs,
) {
private val chars: CharArray = source.text.toCharArray()
private val cellCount = source.cells.size / 2
private val cellCol = IntArray(cellCount)
private val cellSpan = IntArray(cellCount)
private val cellStart = IntArray(cellCount)
private val cellEnd = IntArray(cellCount)
private val cellForeground = IntArray(cellCount)
private val cellBackground = IntArray(cellCount)
private val cellFlags = IntArray(cellCount)
private val cellGlyph = arrayOfNulls<TerminalCustomGlyph>(cellCount)
private val groups = ArrayList<Group>()
private val measured: MeasuredText?
val text: String get() = source.text
private var node: RenderNode? = null
private class Group(
val firstCell: Int,
val lastCell: Int,
val col: Int,
val start: Int,
val end: Int,
val contextStart: Int,
val contextEnd: Int,
val offsetPx: Float,
val scale: Float,
val blank: Boolean,
)
init {
readCells()
measured = measure()
if (measured != null) buildGroups(measured)
}
private fun readCells() {
var offset = 0
var col = 0
var styleIndex = 0
var styleEndCol = 0
for (cell in 0 until cellCount) {
val length = source.cells[cell * 2]
val span = maxOf(1, source.cells[cell * 2 + 1])
while (col >= styleEndCol && styleIndex + 3 < source.styles.size) {
styleEndCol += source.styles[styleIndex].toInt()
styleIndex += 4
}
val style = styleIndex - 4
if (style >= 0) {
cellForeground[cell] = argb(source.styles[style + 1])
cellBackground[cell] = argb(source.styles[style + 2])
cellFlags[cell] = source.styles[style + 3].toInt()
}
cellCol[cell] = col
cellSpan[cell] = span
cellStart[cell] = offset
cellEnd[cell] = minOf(chars.size, offset + length)
cellGlyph[cell] = customGlyphs[source.text.substring(cellStart[cell], cellEnd[cell])]
offset = cellEnd[cell]
col += span
}
}
/** Same face, not custom glyph: cells shaped together. */
private fun shapesWith(cell: Int, other: Int): Boolean =
cellGlyph[cell] == null && cellGlyph[other] == null &&
TerminalFont.face(cellFlags[cell]) == TerminalFont.face(cellFlags[other])
private fun measure(): MeasuredText? {
if (chars.isEmpty()) return null
val builder = MeasuredText.Builder(chars)
var cell = 0
while (cell < cellCount) {
var last = cell
while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1
val length = cellEnd[last] - cellStart[cell]
if (length > 0) {
if (cellGlyph[cell] != null) {
builder.appendReplacementRun(font.paint(cellFlags[cell]), length, 0f)
} else {
builder.appendStyleRun(font.paint(cellFlags[cell]), length, false)
}
}
cell = last + 1
}
val trailing = chars.size - (if (cellCount > 0) cellEnd[cellCount - 1] else 0)
if (trailing > 0) builder.appendReplacementRun(font.paint(0), trailing, 0f)
return builder.build()
}
private fun buildGroups(measured: MeasuredText) {
// Keep platform graphemes together even when Minikin reports separate advances/cursors.
// Rules follow the OS ICU version: Android 10 splits Indic conjuncts such as स्ते.
val graphemes = BreakIterator.getCharacterInstance()
graphemes.setText(source.text)
var cell = 0
while (cell < cellCount) {
if (cellGlyph[cell] != null) {
cell += 1
continue
}
var last = cell
while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1
val contextStart = cellStart[cell]
val contextEnd = cellEnd[last]
var first = cell
for (next in cell + 1..last) {
val startsCluster =
cellEnd[next] > cellStart[next] &&
measured.getCharWidthAt(cellStart[next]) != 0f &&
graphemes.isBoundary(cellStart[next])
if (startsCluster) {
addGroup(measured, first, next - 1, contextStart, contextEnd)
first = next
}
}
addGroup(measured, first, last, contextStart, contextEnd)
cell = last + 1
}
}
private fun addGroup(measured: MeasuredText, first: Int, last: Int, contextStart: Int, contextEnd: Int) {
val start = cellStart[first]
val end = cellEnd[last]
if (end <= start) return
var columns = 0
for (cell in first..last) columns += cellSpan[cell]
val slot = (columns * font.cellWidthPx).toFloat()
val advance = measured.getWidth(start, end)
val scale = if (advance > slot + OVERFLOW_TOLERANCE_PX) slot / advance else 1f
val offset = if (scale < 1f) 0f else (slot - advance) / 2f
var blank = true
for (index in start until end) {
if (chars[index] != ' ') {
blank = false
break
}
}
groups.add(Group(first, last, cellCol[first], start, end, contextStart, contextEnd, offset, scale, blank))
}
/** Draws the row with its own colors, replaying a recorded display list when possible. */
fun draw(canvas: Canvas, width: Int, paints: TerminalPaints) {
if (!canvas.isHardwareAccelerated) {
drawContent(canvas, -1, null, paints)
return
}
// Marks stacked above or below a letter ink outside the row. The node must not clip them: the
// view redraws every row on each frame, so ink outside the row is never left stale.
val recorded = node ?: RenderNode("terminal-row").also {
it.setClipToBounds(false)
node = it
}
if (!recorded.hasDisplayList() || recorded.width != width) {
recorded.setPosition(0, 0, width, font.cellHeightPx)
val recording = recorded.beginRecording()
try {
drawContent(recording, -1, null, paints)
} finally {
recorded.endRecording()
}
}
canvas.drawRenderNode(recorded)
}
/** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
fun release() {
node?.discardDisplayList()
node = null
}
private fun selected(row: Int, selection: TerminalSelection?, cell: Int): Boolean =
selection?.contains(row, cellCol[cell], cellSpan[cell]) == true
private fun drawContent(canvas: Canvas, row: Int, selection: TerminalSelection?, paints: TerminalPaints) {
val cellWidth = font.cellWidthPx.toFloat()
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
}
val baseline = font.baselinePx.toFloat()
val text = measured
if (text != null) {
for (group in groups) {
if (group.blank) continue
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = cellForeground[first]
var last = first
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
if (clipped) {
val left = if (first > group.firstCell) cellCol[first] * cellWidth else -CLIP_UNBOUNDED_PX
val right =
if (last < group.lastCell) (cellCol[last] + cellSpan[last]) * cellWidth else CLIP_UNBOUNDED_PX
canvas.clipRect(left, -CLIP_UNBOUNDED_PX, right, CLIP_UNBOUNDED_PX)
}
paint.color = color
drawGroupText(canvas, text, group, paint, cellWidth, cellHeight, baseline)
canvas.restore()
first = last + 1
}
}
}
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
}
if (flags and (TerminalStyleFlags.UNDERLINE or TerminalStyleFlags.STRIKETHROUGH) == 0) continue
fill.color = foreground
val left = cellCol[cell] * cellWidth
val right = left + cellSpan[cell] * cellWidth
if (flags and TerminalStyleFlags.UNDERLINE != 0) {
val top = baseline + font.underlineOffsetPx
canvas.drawRect(left, top, right, top + font.underlineThicknessPx, fill)
}
if (flags and TerminalStyleFlags.STRIKETHROUGH != 0) {
val top = baseline + font.strikeOffsetPx
canvas.drawRect(left, top, right, top + font.strikeThicknessPx, fill)
}
}
}
private fun drawGroupText(
canvas: Canvas,
text: MeasuredText,
group: Group,
paint: Paint,
cellWidth: Float,
cellHeight: Float,
baseline: Float,
) {
val x = group.col * cellWidth + group.offsetPx
if (group.scale < 1f) canvas.scale(group.scale, group.scale, x, cellHeight / 2f)
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0x472684FF
}
@@ -1,59 +0,0 @@
/**
* Native terminal renderer. The caller owns terminal state (xterm), input and gestures; this view
* shapes and paints one frame of terminal cells on the authoritative cell grid and reports the
* cell size its font produces.
*
* Colors are unsigned 32-bit ARGB numbers (`0xAARRGGBB`). Alpha 0 means "not painted".
*/
export interface PaseoTerminalFrame {
/** Columns the frame paints. Cells beyond it are clipped. */
columns: number;
rows: PaseoTerminalRow[];
cursor: PaseoTerminalCursor | null;
selection: PaseoTerminalSelection | null;
}
/**
* One terminal row. Cells start at column 0 and are consecutive. `text` is every cell's text in
* order, so cell `i` owns the next `cells[2i]` UTF-16 code units of `text` and covers the next
* `cells[2i + 1]` columns. Style spans are consecutive from column 0 and cover all columns.
*/
export interface PaseoTerminalRow {
text: string;
/** Flattened `[utf16Length, columnSpan]` per cell. */
cells: number[];
/** Flattened `[columnCount, foreground, background, flags]` per style span. */
styles: number[];
}
export interface PaseoTerminalCursor {
row: number;
col: number;
}
/** Inclusive cell range in frame rows. A cell is selected when any of its columns is. */
export interface PaseoTerminalSelection {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
}
export interface PaseoTerminalCellMetrics {
cellWidth: number;
cellHeight: number;
}
export const PASEO_TERMINAL_STYLE_FLAGS = {
bold: 1,
italic: 2,
underline: 4,
strikethrough: 8,
dim: 16,
} as const;
@@ -1,9 +0,0 @@
{
"platforms": ["android", "ios"],
"android": {
"modules": ["sh.paseo.terminal.PaseoTerminalModule"]
},
"ios": {
"modules": ["PaseoTerminalModule"]
}
}
@@ -1,34 +0,0 @@
import { describe, expect, test } from "vitest";
import { encodeTerminalFrame } from "./frame-encoding";
describe("encodeTerminalFrame", () => {
test("puts cells and styles before text, so text may contain separators", () => {
const native = encodeTerminalFrame({
columns: 10,
rows: [
{
text: "안a;b,",
cells: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
styles: [3, 0xffe5e5e5, 0, 0, 3, 0xffff0000, 0, 1],
},
{ text: "", cells: [], styles: [] },
],
cursor: { row: 1, col: 0 },
selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 },
});
expect(native).toEqual({
columns: 10,
rows: ["1,2,1,1,1,1,1,1,1,1;3,4293256677,0,0,3,4294901760,0,1;안a;b,", ";;"],
cursor: { row: 1, col: 0 },
selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 },
});
});
test("leaves out a missing cursor and selection instead of sending null", () => {
const native = encodeTerminalFrame({ columns: 1, rows: [], cursor: null, selection: null });
expect(Object.keys(native)).toEqual(["columns", "rows"]);
});
});
@@ -1,27 +0,0 @@
import type { PaseoTerminalCursor, PaseoTerminalFrame, PaseoTerminalSelection } from "./contract";
/** A frame as it crosses to native. */
export interface NativePaseoTerminalFrame {
columns: number;
rows: string[];
cursor?: PaseoTerminalCursor;
selection?: PaseoTerminalSelection;
}
/**
* Rows cross as `cells;styles;text`, with the numbers comma separated. One string per row keeps
* prop conversion to a single copy instead of one call per number, and the string is also the
* native shaping cache key, so an unchanged row costs one lookup.
*
* A missing cursor or selection is left out rather than sent as `null`: Expo on iOS fails to
* convert `null` into an optional record field and then drops the whole frame.
*/
export function encodeTerminalFrame(frame: PaseoTerminalFrame): NativePaseoTerminalFrame {
const native: NativePaseoTerminalFrame = {
columns: frame.columns,
rows: frame.rows.map((row) => `${row.cells.join(",")};${row.styles.join(",")};${row.text}`),
};
if (frame.cursor) native.cursor = frame.cursor;
if (frame.selection) native.selection = frame.selection;
return native;
}
@@ -1,47 +0,0 @@
import { requireNativeView } from "expo";
import { useCallback, useMemo } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { PaseoTerminalCellMetrics, PaseoTerminalFrame, PaseoTerminalTheme } from "./contract";
import { encodeTerminalCustomGlyphs, type PaseoTerminalCustomGlyph } from "./custom-glyphs";
import { encodeTerminalFrame, type NativePaseoTerminalFrame } from "./frame-encoding";
export * from "./contract";
export interface PaseoTerminalViewProps {
frame: PaseoTerminalFrame;
theme: PaseoTerminalTheme;
fontFamily: string;
/** Font size in points after the system font scale is applied. */
fontSize: number;
onCellMetrics?: (metrics: PaseoTerminalCellMetrics) => void;
style?: StyleProp<ViewStyle>;
}
interface NativePaseoTerminalViewProps extends Omit<
PaseoTerminalViewProps,
"frame" | "onCellMetrics"
> {
frame: NativePaseoTerminalFrame;
customGlyphs: PaseoTerminalCustomGlyph[];
onCellMetrics?: (event: { nativeEvent: PaseoTerminalCellMetrics }) => void;
}
const NativePaseoTerminalView = requireNativeView<NativePaseoTerminalViewProps>("PaseoTerminal");
const CUSTOM_GLYPHS = encodeTerminalCustomGlyphs();
export function PaseoTerminalView({ frame, onCellMetrics, ...props }: PaseoTerminalViewProps) {
const nativeFrame = useMemo(() => encodeTerminalFrame(frame), [frame]);
const handleCellMetrics = useCallback(
(event: { nativeEvent: PaseoTerminalCellMetrics }) => onCellMetrics?.(event.nativeEvent),
[onCellMetrics],
);
return (
<NativePaseoTerminalView
{...props}
frame={nativeFrame}
customGlyphs={CUSTOM_GLYPHS}
onCellMetrics={handleCellMetrics}
/>
);
}
@@ -1,22 +0,0 @@
Pod::Spec.new do |s|
s.name = 'PaseoTerminal'
s.version = '0.1.0'
s.summary = 'Native terminal renderer for Paseo'
s.description = 'Shapes and paints terminal frames on the terminal cell grid'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '15.1' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end
@@ -1,21 +0,0 @@
import ExpoModulesCore
public class PaseoTerminalModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoTerminal")
View(PaseoTerminalView.self) {
Events("onCellMetrics")
Prop("frame") { (view: PaseoTerminalView, frame: TerminalFrameRecord) in view.setFrame(frame) }
Prop("theme") { (view: PaseoTerminalView, theme: TerminalThemeRecord) in view.setTheme(theme) }
Prop("fontFamily") { (view: PaseoTerminalView, family: String) in view.setFontFamily(family) }
Prop("fontSize") { (view: PaseoTerminalView, size: Double) in view.setFontSize(size) }
Prop("customGlyphs") { (view: PaseoTerminalView, glyphs: [TerminalCustomGlyphRecord]) in
view.setCustomGlyphs(glyphs)
}
OnViewDidUpdateProps { (view: PaseoTerminalView) in view.commit() }
}
}
}
@@ -1,177 +0,0 @@
import ExpoModulesCore
import UIKit
private let minCachedRows = 256
private let cursorAlpha: CGFloat = 0.45
/// Paints terminal frames on the cell grid. Row shaping is cached by row content, and a frame
/// repaints only the rows whose layout, selection or cursor changed. A row's glyphs may ink outside
/// the row, so a repaint covers the old and new row's ink and redraws every row whose ink it meets.
final class PaseoTerminalView: ExpoView {
let onCellMetrics = EventDispatcher()
private var fontFamily = "Menlo"
private var fontSize: CGFloat = 12
private var font: TerminalFont?
private var customGlyphs = TerminalCustomGlyphs.empty
private var frameRecord: TerminalFrameRecord?
private var frameChanged = false
private var rows: [TerminalRowLayout] = []
private var columns = 0
private var cursor: (row: Int, col: Int)?
private var selection: TerminalSelection?
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0x4726_84ff)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isOpaque = true
contentMode = .redraw
isAccessibilityElement = false
}
func setFrame(_ record: TerminalFrameRecord) {
frameRecord = record
frameChanged = true
}
func setTheme(_ record: TerminalThemeRecord) {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
func setFontFamily(_ value: String) { fontFamily = value }
func setFontSize(_ value: Double) { fontSize = CGFloat(value) }
func setCustomGlyphs(_ records: [TerminalCustomGlyphRecord]) {
customGlyphs = TerminalCustomGlyphs(records)
rowCache.removeAll()
rows = []
frameChanged = true
}
/// Applies a batch of prop updates: font first, then the frame laid out with it.
func commit() {
let displayScale = traitCollection.displayScale > 0 ? traitCollection.displayScale : UIScreen.main.scale
if font?.sameAs(family: fontFamily, size: fontSize, displayScale: displayScale) != true {
let next = TerminalFont(family: fontFamily, size: fontSize, displayScale: displayScale)
font = next
rowCache.removeAll()
rows = []
frameChanged = true
onCellMetrics(["cellWidth": next.cellWidth, "cellHeight": next.cellHeight])
setNeedsDisplay()
}
if frameChanged {
frameChanged = false
layoutFrame()
}
}
override func traitCollectionDidChange(_ previous: UITraitCollection?) {
super.traitCollectionDidChange(previous)
if previous?.displayScale != traitCollection.displayScale { commit() }
}
override func layoutSubviews() {
super.layoutSubviews()
setNeedsDisplay()
}
private func layoutFrame() {
guard let font, let record = frameRecord else { return }
let previousRows = rows
let previousSelection = selection
let previousCursor = cursor
let previousColumns = columns
columns = record.columns
cursor = record.cursor.map { ($0.row, $0.col) }
selection = record.selection.map(TerminalSelection.init)
rows = record.rows.map { encoded in
if let cached = rowCache[encoded] { return cached }
let layout = TerminalRowLayout(
source: TerminalRowSource(encoded: encoded), font: font, customGlyphs: customGlyphs)
rowCache[encoded] = layout
return layout
}
trimRowCache(keeping: record.rows)
updateAccessibility()
if previousColumns != columns || previousRows.count != rows.count {
setNeedsDisplay()
return
}
for index in rows.indices {
let layoutChanged = previousRows[index] !== rows[index]
let selectionChanged = previousSelection?.columns(onRow: index) != selection?.columns(onRow: index)
let cursorChanged =
(previousCursor?.row == index || cursor?.row == index)
&& (previousCursor?.row != cursor?.row || previousCursor?.col != cursor?.col)
if layoutChanged || selectionChanged || cursorChanged {
let previous = paintedRect(previousRows[index], at: index, font: font)
setNeedsDisplay(previous.union(paintedRect(rows[index], at: index, font: font)))
}
}
}
/// The area row `index` paints, including glyph ink outside the row.
private func paintedRect(_ row: TerminalRowLayout, at index: Int, font: TerminalFont) -> CGRect {
let top = CGFloat(index) * font.cellHeight
return CGRect(x: 0, y: top + row.inkTop, width: bounds.width, height: row.inkBottom - row.inkTop)
}
private func trimRowCache(keeping currentRows: [String]) {
guard rowCache.count > max(minCachedRows, rows.count * 2) else { return }
let current = Set(currentRows)
rowCache = rowCache.filter { current.contains($0.key) }
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(background.cgColor())
context.fill(rect)
guard let font else { return }
let gridWidth = CGFloat(columns) * font.cellWidth
context.saveGState()
context.clip(to: CGRect(x: 0, y: 0, width: min(bounds.width, gridWidth), height: bounds.height))
for (index, row) in rows.enumerated() where paintedRect(row, at: index, font: font).intersects(rect) {
row.draw(in: context, row: index, top: CGFloat(index) * font.cellHeight, selection: selection, paint: rowPaint)
}
if let cursor {
context.setFillColor(cursorColor.cgColor(alphaScale: cursorAlpha))
context.fill(CGRect(
x: CGFloat(cursor.col) * font.cellWidth, y: CGFloat(cursor.row) * font.cellHeight,
width: font.cellWidth, height: font.cellHeight))
}
context.restoreGState()
}
/// Exposes each non-blank row as a static text element, `terminal-row-<index>`.
private func updateAccessibility() {
guard let font else { return }
var elements: [UIAccessibilityElement] = []
for (index, row) in rows.enumerated() {
let label = row.accessibilityText
guard !label.isEmpty else { continue }
let element = UIAccessibilityElement(accessibilityContainer: self)
element.accessibilityIdentifier = "terminal-row-\(index)"
element.accessibilityLabel = label
element.accessibilityTraits = .staticText
element.accessibilityFrameInContainerSpace = CGRect(
x: 0, y: CGFloat(index) * font.cellHeight,
width: min(bounds.width, CGFloat(columns) * font.cellWidth), height: font.cellHeight)
elements.append(element)
}
accessibilityElements = elements
}
}
@@ -1,73 +0,0 @@
import CoreGraphics
/// Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and
/// blocks join across cells regardless of the font's own metrics.
final class TerminalCustomGlyph {
private let rects: [Double]
private let commands: [Double]
private let strokeWidth: CGFloat
init(_ record: TerminalCustomGlyphRecord) {
rects = record.rects
commands = record.path
strokeWidth = CGFloat(record.strokeWidth)
}
func draw(in context: CGContext, rect cell: CGRect, color: CGColor) {
context.saveGState()
context.setFillColor(color)
var index = 0
while index + 3 < rects.count {
context.fill(CGRect(
x: cell.minX + CGFloat(rects[index]) * cell.width,
y: cell.minY + CGFloat(rects[index + 1]) * cell.height,
width: CGFloat(rects[index + 2]) * cell.width,
height: CGFloat(rects[index + 3]) * cell.height))
index += 4
}
if !commands.isEmpty {
let point = { (offset: Int) in
CGPoint(
x: cell.minX + CGFloat(self.commands[offset]) * cell.width,
y: cell.minY + CGFloat(self.commands[offset + 1]) * cell.height)
}
let path = CGMutablePath()
index = 0
while index < commands.count {
switch Int(commands[index]) {
case 0:
path.move(to: point(index + 1))
index += 3
case 1:
path.addLine(to: point(index + 1))
index += 3
case 2:
path.addQuadCurve(to: point(index + 3), control: point(index + 1))
index += 5
default:
index = commands.count
}
}
context.setStrokeColor(color)
context.setLineWidth(strokeWidth)
context.setLineCap(.butt)
context.setLineJoin(.miter)
context.addPath(path)
context.strokePath()
}
context.restoreGState()
}
}
final class TerminalCustomGlyphs {
static let empty = TerminalCustomGlyphs([])
private let glyphs: [String: TerminalCustomGlyph]
init(_ records: [TerminalCustomGlyphRecord]) {
var glyphs: [String: TerminalCustomGlyph] = [:]
for record in records { glyphs[record.char] = TerminalCustomGlyph(record) }
self.glyphs = glyphs
}
subscript(text: String) -> TerminalCustomGlyph? { glyphs[text] }
}
@@ -1,67 +0,0 @@
import CoreText
import UIKit
private let measureText = "mmmmmmmmmm"
/// The terminal font at one size: a CTFont per face (regular, bold, italic, bold italic) and the
/// cell those faces are laid out on. Cells are whole device pixels so every column origin is
/// exact. CoreText's cascade list covers characters the family lacks.
final class TerminalFont {
let family: String
let size: CGFloat
let displayScale: CGFloat
private let faces: [CTFont]
/// Cell size and baseline in points, each a whole number of device pixels.
let cellWidth: CGFloat
let cellHeight: CGFloat
let baseline: CGFloat
let underlineOffset: CGFloat
let underlineThickness: CGFloat
let strikeOffset: CGFloat
let strikeThickness: CGFloat
init(family: String, size: CGFloat, displayScale: CGFloat) {
self.family = family
self.size = size
self.displayScale = displayScale
let regular = TerminalFont.makeFont(family: family, size: size, traits: [])
faces = [
regular,
TerminalFont.makeFont(family: family, size: size, traits: .traitBold),
TerminalFont.makeFont(family: family, size: size, traits: .traitItalic),
TerminalFont.makeFont(family: family, size: size, traits: [.traitBold, .traitItalic]),
]
let pixel = { (value: CGFloat) in max(1, (value * displayScale).rounded()) / displayScale }
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: measureText, attributes: [.font: regular]))
let width = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
cellWidth = pixel(width / CGFloat(measureText.count))
let ascent = CTFontGetAscent(regular)
let descent = CTFontGetDescent(regular)
let leading = CTFontGetLeading(regular)
cellHeight = pixel(ascent + descent + leading)
baseline = pixel(ascent)
underlineOffset = -CTFontGetUnderlinePosition(regular)
underlineThickness = max(1 / displayScale, CTFontGetUnderlineThickness(regular))
strikeOffset = -CTFontGetXHeight(regular) / 2
strikeThickness = underlineThickness
}
func font(flags: Int) -> CTFont { faces[TerminalFont.face(flags: flags)] }
func sameAs(family: String, size: CGFloat, displayScale: CGFloat) -> Bool {
self.family == family && self.size == size && self.displayScale == displayScale
}
static func face(flags: Int) -> Int {
(flags & TerminalStyleFlags.bold != 0 ? 1 : 0) + (flags & TerminalStyleFlags.italic != 0 ? 2 : 0)
}
private static func makeFont(family: String, size: CGFloat, traits: UIFontDescriptor.SymbolicTraits) -> CTFont {
let base = UIFontDescriptor(fontAttributes: [.family: family])
let descriptor = traits.isEmpty ? base : (base.withSymbolicTraits(traits) ?? base)
return UIFont(descriptor: descriptor, size: size) as CTFont
}
}
@@ -1,121 +0,0 @@
import CoreGraphics
import ExpoModulesCore
struct TerminalCursorRecord: Record {
@Field var row: Int = 0
@Field var col: Int = 0
}
struct TerminalSelectionRecord: Record {
@Field var startRow: Int = 0
@Field var startCol: Int = 0
@Field var endRow: Int = 0
@Field var endCol: Int = 0
}
struct TerminalFrameRecord: Record {
@Field var columns: Int = 0
/// Encoded rows, `cells;styles;text`; see `frame-encoding.ts`.
@Field var rows: [String] = []
@Field var cursor: TerminalCursorRecord? = nil
@Field var selection: TerminalSelectionRecord? = nil
}
struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@Field var char: String = ""
@Field var rects: [Double] = []
@Field var path: [Double] = []
@Field var strokeWidth: Double = 0
}
enum TerminalStyleFlags {
static let bold = 1
static let italic = 2
static let underline = 4
static let strikethrough = 8
}
/// JS sends colors as unsigned ARGB numbers.
struct TerminalColor: Equatable {
let argb: UInt32
init(_ value: Double) {
argb = UInt32(truncatingIfNeeded: Int64(value))
}
var isPainted: Bool { argb >> 24 != 0 }
func cgColor(alphaScale: CGFloat = 1) -> CGColor {
let channel = { (shift: UInt32) in CGFloat((argb >> shift) & 0xff) / 255 }
return CGColor(srgbRed: channel(16), green: channel(8), blue: channel(0), alpha: channel(24) * alphaScale)
}
}
/// One row as the caller described it: cell text, column spans and style spans.
struct TerminalRowSource {
let text: String
let cells: [Int]
let styles: [Double]
/// Decodes `cells;styles;text`, where both number lists are comma separated.
init(encoded: String) {
let utf8 = encoded.utf8
let separator = UInt8(ascii: ";")
let cellsEnd = utf8.firstIndex(of: separator) ?? utf8.endIndex
let stylesStart = cellsEnd == utf8.endIndex ? cellsEnd : utf8.index(after: cellsEnd)
let stylesEnd = utf8[stylesStart...].firstIndex(of: separator) ?? utf8.endIndex
let textStart = stylesEnd == utf8.endIndex ? stylesEnd : utf8.index(after: stylesEnd)
cells = TerminalRowSource.parseNumbers(utf8[utf8.startIndex..<cellsEnd]).map { Int($0) }
styles = TerminalRowSource.parseNumbers(utf8[stylesStart..<stylesEnd]).map { Double($0) }
text = String(encoded[textStart...])
}
private static func parseNumbers(_ bytes: Substring.UTF8View) -> [Int64] {
guard !bytes.isEmpty else { return [] }
var numbers: [Int64] = [0]
for byte in bytes {
if byte == UInt8(ascii: ",") {
numbers.append(0)
} else {
numbers[numbers.count - 1] = numbers[numbers.count - 1] * 10 + Int64(byte - UInt8(ascii: "0"))
}
}
return numbers
}
}
struct TerminalSelection: Equatable {
let startRow: Int
let startCol: Int
let endRow: Int
let endCol: Int
init(_ record: TerminalSelectionRecord) {
startRow = record.startRow
startCol = record.startCol
endRow = record.endRow
endCol = record.endCol
}
func intersects(row: Int) -> Bool { row >= startRow && row <= endRow }
/// A cell is selected when any column it covers is inside the inclusive range.
func contains(row: Int, col: Int, span: Int) -> Bool {
guard intersects(row: row) else { return false }
let firstCol = row == startRow ? startCol : 0
let lastCol = row == endRow ? endCol : Int.max
return col + span - 1 >= firstCol && col <= lastCol
}
/// The part of the selection on one row, so a row only repaints when its own part changes.
func columns(onRow row: Int) -> ClosedRange<Int>? {
guard intersects(row: row) else { return nil }
return (row == startRow ? startCol : 0)...(row == endRow ? endCol : Int.max)
}
}
@@ -1,336 +0,0 @@
import CoreText
import UIKit
/// Shaped glyphs may exceed their slot by this many points before they are scaled down.
private let overflowTolerance: CGFloat = 0.5
/// Stands in for an open edge when a group is clipped on one side only.
private let clipUnbounded: CGFloat = 100_000
/// Lays text out left to right in logical order. The override fixes every character's direction;
/// the base direction also covers trailing spaces, which bidi rule L1 otherwise moves to the
/// direction detected from the first strong character, ahead of an Arabic or Hebrew row.
private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
var base = CTWritingDirection.leftToRight
let paragraph = withUnsafeBytes(of: &base) { bytes in
CTParagraphStyleCreate(
[CTParagraphStyleSetting(
spec: .baseWritingDirection, valueSize: MemoryLayout<CTWritingDirection>.size, value: bytes.baseAddress!)],
1)
}
let overrideLeftToRight = CTWritingDirection.leftToRight.rawValue | 2 // kCTWritingDirectionOverride
return [
NSAttributedString.Key(kCTWritingDirectionAttributeName as String): [NSNumber(value: overrideLeftToRight)],
NSAttributedString.Key(kCTParagraphStyleAttributeName as String): paragraph,
]
}()
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
///
/// Each run of cells with the same font face is shaped as a whole line, in logical order and left
/// to right like the terminal grid, so contextual forms, ligatures, marks and fallback fonts see
/// their neighbours. CoreText reports which UTF-16 offset produced each glyph. Cells whose units
/// produced no advancing glyph, and cells a reordered glyph crosses, join the cluster of the cell
/// before them: a ligature, conjunct, emoji sequence or reordered mark that spans columns. Each
/// group is drawn at its first cell's column with the glyph offsets CoreText produced, centred in
/// the columns it owns and scaled down only when wider than them. Colors never split groups.
final class TerminalRowLayout {
let source: TerminalRowSource
private let font: TerminalFont
private let units: [UInt16]
private let cellCount: Int
private var cellCol: [Int]
private var cellSpan: [Int]
private var cellStart: [Int]
private var cellEnd: [Int]
private var cellForeground: [TerminalColor]
private var cellBackground: [TerminalColor]
private var cellFlags: [Int]
private var cellGlyph: [TerminalCustomGlyph?]
private var groups: [Group] = []
/// The row's painted extent, relative to its top edge. Marks stacked above or below a
/// letter and some fallback glyphs ink outside the row; the view repaints that area with it.
private(set) var inkTop: CGFloat = 0
private(set) var inkBottom: CGFloat
private struct GlyphRun {
let font: CTFont
var glyphs: [CGGlyph]
/// Relative to the group's pen origin, y up from the baseline.
var positions: [CGPoint]
}
private struct Group {
let firstCell: Int
let lastCell: Int
let col: Int
let offset: CGFloat
let scale: CGFloat
let runs: [GlyphRun]
}
private struct ShapedGlyph {
let font: CTFont
let glyph: CGGlyph
let position: CGPoint
let advance: CGFloat
let cell: Int
}
init(source: TerminalRowSource, font: TerminalFont, customGlyphs: TerminalCustomGlyphs) {
self.source = source
self.font = font
units = Array(source.text.utf16)
cellCount = source.cells.count / 2
cellCol = Array(repeating: 0, count: cellCount)
cellSpan = Array(repeating: 1, count: cellCount)
cellStart = Array(repeating: 0, count: cellCount)
cellEnd = Array(repeating: 0, count: cellCount)
cellForeground = Array(repeating: TerminalColor(0), count: cellCount)
cellBackground = Array(repeating: TerminalColor(0), count: cellCount)
cellFlags = Array(repeating: 0, count: cellCount)
cellGlyph = Array(repeating: nil, count: cellCount)
inkBottom = font.cellHeight
readCells(customGlyphs: customGlyphs)
shapeRuns()
}
var accessibilityText: String {
var text = source.text
while text.last == " " { text.removeLast() }
return text
}
private func readCells(customGlyphs: TerminalCustomGlyphs) {
var offset = 0
var col = 0
var styleIndex = 0
var styleEndCol = 0
for cell in 0..<cellCount {
let length = source.cells[cell * 2]
let span = max(1, source.cells[cell * 2 + 1])
while col >= styleEndCol && styleIndex + 3 < source.styles.count {
styleEndCol += Int(source.styles[styleIndex])
styleIndex += 4
}
let style = styleIndex - 4
if style >= 0 {
cellForeground[cell] = TerminalColor(source.styles[style + 1])
cellBackground[cell] = TerminalColor(source.styles[style + 2])
cellFlags[cell] = Int(source.styles[style + 3])
}
cellCol[cell] = col
cellSpan[cell] = span
cellStart[cell] = offset
cellEnd[cell] = min(units.count, offset + length)
cellGlyph[cell] = customGlyphs[text(from: cellStart[cell], to: cellEnd[cell])]
offset = cellEnd[cell]
col += span
}
}
private func text(from start: Int, to end: Int) -> String {
String(utf16CodeUnits: Array(units[start..<end]), count: end - start)
}
private func shapesWith(_ cell: Int, _ other: Int) -> Bool {
cellGlyph[cell] == nil && cellGlyph[other] == nil
&& TerminalFont.face(flags: cellFlags[cell]) == TerminalFont.face(flags: cellFlags[other])
}
private func shapeRuns() {
var cell = 0
while cell < cellCount {
if cellGlyph[cell] != nil {
cell += 1
continue
}
var last = cell
while last + 1 < cellCount && shapesWith(cell, last + 1) { last += 1 }
shapeRun(first: cell, last: last)
cell = last + 1
}
}
private func shapeRun(first: Int, last: Int) {
let start = cellStart[first]
let end = cellEnd[last]
guard end > start else { return }
var cellOfUnit = Array(repeating: first, count: end - start)
for cell in first...last where cellEnd[cell] > cellStart[cell] {
for unit in cellStart[cell]..<cellEnd[cell] { cellOfUnit[unit - start] = cell }
}
var attributes = leftToRightAttributes
attributes[NSAttributedString.Key(kCTFontAttributeName as String)] = font.font(flags: cellFlags[first])
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: text(from: start, to: end), attributes: attributes))
var shaped: [ShapedGlyph] = []
for run in CTLineGetGlyphRuns(line) as? [CTRun] ?? [] {
let count = CTRunGetGlyphCount(run)
guard count > 0 else { continue }
let runAttributes = CTRunGetAttributes(run) as NSDictionary
let runFont = runAttributes[kCTFontAttributeName as String].map { $0 as! CTFont }
?? font.font(flags: cellFlags[first])
var glyphs = Array(repeating: CGGlyph(0), count: count)
var positions = Array(repeating: CGPoint.zero, count: count)
var advances = Array(repeating: CGSize.zero, count: count)
var indices = Array(repeating: CFIndex(0), count: count)
let all = CFRange(location: 0, length: 0)
CTRunGetGlyphs(run, all, &glyphs)
CTRunGetPositions(run, all, &positions)
CTRunGetAdvances(run, all, &advances)
CTRunGetStringIndices(run, all, &indices)
for index in 0..<count {
let unit = min(max(0, indices[index]), cellOfUnit.count - 1)
shaped.append(ShapedGlyph(
font: runFont, glyph: glyphs[index], position: positions[index],
advance: advances[index].width, cell: cellOfUnit[unit]))
}
}
// A cell starts a cluster unless a reordered glyph crosses into it or it has no advance.
var startsCluster = Array(repeating: true, count: last - first + 1)
var advance = Array(repeating: CGFloat(0), count: last - first + 1)
var furthestCell = first
for glyph in shaped {
advance[glyph.cell - first] += glyph.advance
if glyph.cell < furthestCell {
for cell in (glyph.cell + 1)...furthestCell { startsCluster[cell - first] = false }
} else {
furthestCell = glyph.cell
}
}
if last > first {
for cell in (first + 1)...last where advance[cell - first] == 0 {
startsCluster[cell - first] = false
}
}
var groupFirst = first
for cell in first...last {
let closesGroup = cell == last || startsCluster[cell + 1 - first]
guard closesGroup else { continue }
addGroup(first: groupFirst, last: cell, glyphs: shaped.filter { $0.cell >= groupFirst && $0.cell <= cell })
groupFirst = cell + 1
}
}
private func addGroup(first: Int, last: Int, glyphs: [ShapedGlyph]) {
let isBlank = (cellStart[first]..<cellEnd[last]).allSatisfy { units[$0] == 0x20 }
guard let origin = glyphs.first?.position.x, !isBlank else { return }
let columns = (first...last).reduce(0) { $0 + cellSpan[$1] }
let slot = CGFloat(columns) * font.cellWidth
let advance = glyphs.reduce(CGFloat(0)) { $0 + $1.advance }
let scale = advance > slot + overflowTolerance ? slot / advance : 1
let offset = scale < 1 ? 0 : (slot - advance) / 2
var runs: [GlyphRun] = []
for glyph in glyphs {
let position = CGPoint(x: glyph.position.x - origin, y: glyph.position.y)
if let lastRun = runs.last, CFEqual(lastRun.font, glyph.font) {
runs[runs.count - 1].glyphs.append(glyph.glyph)
runs[runs.count - 1].positions.append(position)
} else {
runs.append(GlyphRun(font: glyph.font, glyphs: [glyph.glyph], positions: [position]))
}
}
groups.append(Group(
firstCell: first, lastCell: last, col: cellCol[first], offset: offset, scale: scale, runs: runs))
extendInk(runs: runs, scale: scale)
}
/// Grows the row's ink extent to cover the glyphs as `draw` places them, padded by a device pixel
/// for antialiasing.
private func extendInk(runs: [GlyphRun], scale: CGFloat) {
let centerY = font.cellHeight / 2
let baselineInTextSpace = font.cellHeight / 2 - font.baseline
let pixel = 1 / font.displayScale
for run in runs {
var bounds = Array(repeating: CGRect.zero, count: run.glyphs.count)
CTFontGetBoundingRectsForGlyphs(run.font, .horizontal, run.glyphs, &bounds, run.glyphs.count)
for (glyph, position) in zip(bounds, run.positions) where !glyph.isEmpty {
let top = centerY - (position.y + baselineInTextSpace + glyph.maxY) * scale - pixel
let bottom = centerY - (position.y + baselineInTextSpace + glyph.minY) * scale + pixel
inkTop = min(inkTop, (top * font.displayScale).rounded(.down) / font.displayScale)
inkBottom = max(inkBottom, (bottom * font.displayScale).rounded(.up) / font.displayScale)
}
}
}
private func isSelected(_ cell: Int, row: Int, selection: TerminalSelection?) -> Bool {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth
let cellHeight = font.cellHeight
for cell in 0..<cellCount {
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
let background = cellBackground[cell]
if background.isPainted {
context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
}
let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups {
var first = group.firstCell
while first <= group.lastCell {
let color = cellForeground[first]
var last = first
while last < group.lastCell && cellForeground[last + 1] == color {
last += 1
}
context.saveGState()
if first > group.firstCell || last < group.lastCell {
let left = first > group.firstCell ? CGFloat(cellCol[first]) * cellWidth : -clipUnbounded
let right = last < group.lastCell ? CGFloat(cellCol[last] + cellSpan[last]) * cellWidth : clipUnbounded
context.clip(to: CGRect(x: left, y: -clipUnbounded, width: right - left, height: 2 * clipUnbounded))
}
context.setFillColor(color.cgColor())
context.translateBy(x: CGFloat(group.col) * cellWidth + group.offset, y: centerY)
context.scaleBy(x: group.scale, y: -group.scale)
context.textMatrix = .identity
for run in group.runs {
let positions = run.positions.map { CGPoint(x: $0.x, y: $0.y + baselineInTextSpace) }
CTFontDrawGlyphs(run.font, run.glyphs, positions, run.glyphs.count, context)
}
context.restoreGState()
first = last + 1
}
}
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
glyph.draw(in: context, rect: CGRect(x: left, y: top, width: width, height: cellHeight), color: color.cgColor())
}
let flags = cellFlags[cell]
if flags & (TerminalStyleFlags.underline | TerminalStyleFlags.strikethrough) == 0 { continue }
context.setFillColor(color.cgColor())
if flags & TerminalStyleFlags.underline != 0 {
context.fill(CGRect(x: left, y: baseline + font.underlineOffset, width: width, height: font.underlineThickness))
}
if flags & TerminalStyleFlags.strikethrough != 0 {
context.fill(CGRect(x: left, y: baseline + font.strikeOffset, width: width, height: font.strikeThickness))
}
}
}
}
@@ -1,5 +0,0 @@
{
"name": "paseo-terminal",
"version": "0.1.0",
"private": true
}
-1
View File
@@ -154,7 +154,6 @@
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
-2
View File
@@ -35,7 +35,6 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { OnboardingHost } from "@/onboarding";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -495,7 +494,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -1,280 +0,0 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { PluginThemeOption } from "@/plugins/themes";
import {
THEME_OPTIONS,
PLUGIN_THEME_PREFERENCE,
darkTheme,
lightTheme,
type Theme,
} from "@/styles/theme";
import { useContributedThemes } from "./provider";
type BuiltInThemePreference = Exclude<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
// System leads the grid because it is the default.
const PICKER_THEME_OPTIONS = [
...THEME_OPTIONS.filter((option) => option.name === "auto"),
...THEME_OPTIONS.filter((option) => option.name !== "auto"),
];
const PREVIEW_WIDTH = 128;
const PREVIEW_HEIGHT = 72;
/**
* Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies
* the theme to the whole app at once. Settings → Appearance and first-run setup share it.
*/
export function ThemePicker() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes();
// A selected contribution that is no longer installed shows the fallback the app renders.
const builtInValue =
settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme;
const selectBuiltIn = useCallback(
(theme: BuiltInThemePreference) => {
void updateSettings({ theme });
},
[updateSettings],
);
return (
<View
style={styles.grid}
accessibilityRole="radiogroup"
accessibilityLabel={t("settings.appearance.theme.title")}
>
{PICKER_THEME_OPTIONS.map((option) => (
<BuiltInThemeTile
key={option.name}
value={option.name}
selected={selectedPluginTheme === null && builtInValue === option.name}
onSelect={selectBuiltIn}
/>
))}
{pluginThemes.map((option) => (
<PluginThemeTile
key={option.id}
option={option}
selected={selectedPluginTheme?.id === option.id}
onSelect={select}
/>
))}
</View>
);
}
function BuiltInThemeTile({
value,
selected,
onSelect,
}: {
value: BuiltInThemePreference;
selected: boolean;
onSelect: (value: BuiltInThemePreference) => void;
}) {
const { t } = useTranslation();
const handlePress = useCallback(() => onSelect(value), [onSelect, value]);
const option = THEME_OPTIONS.find((candidate) => candidate.name === value);
return (
<ThemeTile
label={t(`settings.appearance.theme.options.${value}`)}
selected={selected}
onPress={handlePress}
previewTheme={option && "theme" in option ? option.theme : "system"}
/>
);
}
function PluginThemeTile({
option,
selected,
onSelect,
}: {
option: PluginThemeOption;
selected: boolean;
onSelect: (option: PluginThemeOption) => void;
}) {
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
return (
<ThemeTile
label={option.name}
selected={selected}
onPress={handlePress}
previewTheme={option.theme}
/>
);
}
function ThemeTile({
label,
selected,
onPress,
previewTheme,
}: {
label: string;
selected: boolean;
onPress: () => void;
/** "system" follows the OS, so its preview shows both halves. */
previewTheme: Theme | "system";
}) {
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
const tileStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.tile,
hovered && !selected && styles.tileHovered,
selected && styles.tileSelected,
],
[selected],
);
return (
<PressHighlight
style={tileStyle}
onPress={onPress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={styles.previewFrame}>
{previewTheme === "system" ? <SystemThemePreview /> : <ThemePreview theme={previewTheme} />}
</View>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */
function ThemePreview({ theme }: { theme: Theme }) {
const colors = theme.colors;
const previewStyles = useMemo(
() => ({
root: [styles.preview, { backgroundColor: colors.surface0 }],
sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }],
mutedLine: { backgroundColor: colors.foregroundMuted },
strongLine: { backgroundColor: colors.foreground },
bubble: [styles.previewBubble, { backgroundColor: colors.accent }],
}),
[colors],
);
return (
<View style={previewStyles.root}>
<View style={previewStyles.sidebar}>
<View style={[styles.previewLine, styles.sidebarLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineNarrow, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineMedium, previewStyles.mutedLine]} />
</View>
<View style={styles.previewMain}>
<View style={[styles.previewTitle, previewStyles.strongLine]} />
<View style={[styles.previewLine, styles.mainLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.mainLineNarrow, previewStyles.mutedLine]} />
<View style={previewStyles.bubble} />
</View>
</View>
);
}
/** "System" follows the OS: light on the left half, dark on the right. */
function SystemThemePreview() {
return (
<View style={styles.preview}>
<ThemePreview theme={lightTheme} />
<View style={styles.systemDarkHalf}>
<View style={styles.systemDarkOffset}>
<ThemePreview theme={darkTheme} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[3],
},
tile: {
width: PREVIEW_WIDTH + theme.spacing[1] * 2 + theme.borderWidth[2] * 2,
padding: theme.spacing[1],
gap: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[2],
borderColor: "transparent",
},
tileHovered: {
backgroundColor: theme.colors.surface2,
},
tileSelected: {
borderColor: theme.colors.accent,
},
previewFrame: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[1],
},
preview: {
width: PREVIEW_WIDTH,
height: PREVIEW_HEIGHT,
flexDirection: "row",
overflow: "hidden",
},
previewSidebar: {
width: 36,
paddingHorizontal: 6,
paddingVertical: 8,
gap: 5,
},
previewMain: {
flex: 1,
padding: 8,
gap: 5,
},
previewLine: {
height: 4,
borderRadius: 2,
opacity: 0.6,
},
previewTitle: {
width: "55%",
height: 5,
borderRadius: 2,
},
sidebarLineWide: { width: "100%" },
sidebarLineNarrow: { width: "60%" },
sidebarLineMedium: { width: "80%" },
mainLineWide: { width: "90%" },
mainLineNarrow: { width: "65%" },
previewBubble: {
marginTop: "auto",
alignSelf: "flex-end",
width: "45%",
height: 10,
borderRadius: 5,
},
systemDarkHalf: {
position: "absolute",
top: 0,
bottom: 0,
left: PREVIEW_WIDTH / 2,
width: PREVIEW_WIDTH / 2,
overflow: "hidden",
},
systemDarkOffset: {
marginLeft: -PREVIEW_WIDTH / 2,
},
}));
+9 -4
View File
@@ -64,7 +64,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -475,10 +480,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const usageSummary = useUsageSummary();
const hasUsageSummary = useHasUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
);
if (visibleItems.length === 0) return null;
return (
@@ -494,7 +499,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} sources={usageSummary} />
<UsageSidebarItem key={item.key} />
),
)}
</View>
@@ -1,8 +1,7 @@
import { useMemo } from "react";
import { Animated, View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconRadius } from "@/components/project-icon-view";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
const PROJECT_ICON_SIZE = 16;
@@ -12,6 +11,17 @@ const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.ma
[0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`),
);
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
function SkeletonSection({
pulse,
sectionOpacity,
@@ -47,7 +57,27 @@ function SkeletonSection({
}
export function SidebarAgentListSkeleton() {
const pulse = useSkeletonPulse();
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulse, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return (
<View style={styles.container}>
@@ -4,6 +4,7 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -18,7 +19,8 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -42,6 +44,7 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -1,17 +1,19 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import {
Keyboard,
type LayoutChangeEvent,
PanResponder,
type PanResponderGestureState,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
TerminalInputModeTracker,
terminalInputModeStatesEqual,
@@ -46,10 +48,13 @@ import {
type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
@@ -83,8 +88,75 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6",
cursor: "#e6e6e6",
};
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize {
return {
rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS,
cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS,
};
}
function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState {
const visibleRows = viewport.grid.length;
const currentViewport = {
firstRow: viewport.firstRow,
lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1),
};
return {
viewport,
scroll: {
mode: "following",
firstRow: viewport.firstRow,
visibleRows,
oldestRow: viewport.oldestRow,
newestRow: viewport.newestRow,
currentViewport,
bottomViewport: currentViewport,
},
};
}
function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState {
return {
rows: state.grid.length,
cols: state.cols,
firstRow: 0,
oldestRow: 0,
newestRow: Math.max(0, state.grid.length - 1),
grid: state.grid,
cursor: state.cursor,
};
}
function NativeTerminalEmulator({
@@ -97,6 +169,7 @@ function NativeTerminalEmulator({
fontSize,
keyboardInset = 0,
isKeyboardVisible = false,
initialSnapshot = null,
onInput,
onTerminalKey,
onResize,
@@ -108,18 +181,20 @@ function NativeTerminalEmulator({
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey });
const mountInputRef = useRef({ streamKey, initialSnapshot });
if (
resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey,
}) === "mount"
) {
mountInputRef.current = { streamKey };
mountInputRef.current = { streamKey, initialSnapshot };
}
const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot));
const resizePolicyRef = useRef(
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -127,20 +202,29 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionControllerRef = useRef<ReturnType<
typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false,
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => {
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false);
@@ -204,7 +288,6 @@ function NativeTerminalEmulator({
}, []);
const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range);
}, [commitSelectionRange]);
@@ -215,14 +298,20 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range);
},
[commitSelectionRange],
);
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return (
resolveMeasuredNativeTerminalSize({
@@ -272,14 +361,10 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({
write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk);
emitInputModeChange();
},
restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
reset: () => {
terminal.reset();
emitInputModeChange();
},
@@ -330,11 +415,11 @@ function NativeTerminalEmulator({
return;
}
const isFirstMeasuredSize = !hasNotifiedMeasuredSizeRef.current;
hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) {
outputDrainRef.current?.resize();
terminalRef.current?.resize(policyResult.measuredSize);
schedulePaint();
}
callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows,
@@ -342,60 +427,69 @@ function NativeTerminalEmulator({
shouldClaim: policyResult.resizeClaim !== null,
forceClaim: policyResult.resizeClaim?.force,
});
// Ready waits for the grid size, so the pane attaches with it and the daemon replays the
// terminal at that size instead of the PTY's.
if (isFirstMeasuredSize) {
callbacksRef.current.onRendererReadyChange?.({
streamKey: mountInputRef.current.streamKey,
isReady: true,
});
}
},
[],
[schedulePaint],
);
const resetTerminal = useCallback(() => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size = measuredSize ?? terminalSizeRef.current;
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
const resetTerminal = useCallback(
(snapshot: TerminalState | null) => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size =
measuredSize ??
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current);
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = snapshot
? screenStateFromViewport(viewportStateFromSnapshot(snapshot))
: resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
if (snapshot) {
outputDrain.restoreSnapshot(snapshot);
}
},
[
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
}, [
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
clearSelection,
]);
clearSelection,
],
);
const enqueueOutputText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
outputDrainRef.current?.enqueueText(text);
}, []);
const enqueueOutputText = useCallback(
(text: string) => {
if (text.length === 0) {
return;
}
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1;
@@ -418,7 +512,27 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text);
},
replay: (event) => outputDrainRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
const text = outputDecoderRef.current.decode(data, { stream: false });
outputDrainRef.current?.restoreText(text);
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
if (!state) {
resetTerminal(null);
return;
}
clearSelection();
const outputDrain = outputDrainRef.current;
if (!outputDrain) {
resetTerminal(state);
return;
}
outputDrain.restoreSnapshot(state);
},
paste: (text: string) => {
if (text.length === 0) {
return;
@@ -449,6 +563,7 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset();
clearSelection();
resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint();
},
claimSize: claimActiveTerminalSize,
@@ -467,17 +582,19 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction,
clearSelection,
resetInputModeTracker,
resetTerminal,
schedulePaint,
],
);
useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false;
resetTerminal();
emitMeasuredSize({ source: "measure" });
resetTerminal(mountInputRef.current.initialSnapshot);
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
selectionControllerRef.current?.cancel();
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null;
@@ -487,7 +604,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [emitMeasuredSize, resetTerminal, streamKey]);
}, [clearSelectionLongPressTimeout, resetTerminal, streamKey]);
useEffect(() => {
if (focusRequestToken <= 0) return;
@@ -544,7 +661,6 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" });
},
[emitMeasuredSize],
@@ -617,6 +733,14 @@ function NativeTerminalEmulator({
if (!coordinate) {
return;
}
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({
@@ -631,61 +755,222 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate],
);
const selectionController = useMemo(
() =>
createTerminalSelectionController({
getGeometry: () => {
const viewport = resolveSelectionViewport();
const metrics = metricsRef.current;
return viewport && metrics
? { range: selectionRangeRef.current, viewport, metrics }
: null;
},
onBeginHandle: (endpoint, cols) =>
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
const terminal = terminalRef.current;
if (!terminal) return;
commitSelectionRange(
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
.range,
);
},
onLongPress: beginSelectionAtPoint,
onTap: focusTerminalFromTap,
onScroll: applyScroll,
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
cancel: (timer) => clearTimeout(timer),
}),
[
applyScroll,
beginSelectionAtPoint,
commitSelectionRange,
focusTerminalFromTap,
resolveSelectionViewport,
],
const updateSelectionAtPoint = useCallback(
(point: { x: number; y: number }) => {
const terminal = terminalRef.current;
if (!terminal || selectionPressRef.current?.status !== "selecting") {
return;
}
const coordinate = resolveSelectionCoordinate(point);
if (!coordinate) {
return;
}
const snapshot = selectionModelRef.current.update({
coordinate,
bounds: terminal.getBufferBounds(),
});
commitSelectionRange(snapshot.range);
},
[commitSelectionRange, resolveSelectionCoordinate],
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
);
selectionControllerRef.current = selectionController;
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
gestureStartRef.current = point;
selectionController.press(point);
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
},
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}),
[selectionController],
[
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -696,11 +981,6 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? (
<TerminalGridView
state={terminalState}
@@ -717,41 +997,6 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
@@ -794,8 +1039,6 @@ const styles = StyleSheet.create({
nativeGrid: {
flex: 1,
},
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: {
position: "absolute",
right: 12,
@@ -19,6 +19,7 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
@@ -39,7 +40,8 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -106,6 +108,7 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -144,6 +147,16 @@ declare global {
interface Window {}
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
export default function TerminalEmulator({
ref,
streamKey,
@@ -159,6 +172,7 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
@@ -214,6 +228,8 @@ export default function TerminalEmulator({
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
@@ -237,17 +253,17 @@ export default function TerminalEmulator({
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
replay: (event) => {
if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
if (event.type === "data" && event.data instanceof Uint8Array)
runtimeRef.current?.replay({ type: "data", data: event.data });
if (event.type === "begin")
runtimeRef.current?.replay({
type: "begin",
rows: typeof event.rows === "number" ? event.rows : undefined,
cols: typeof event.cols === "number" ? event.cols : undefined,
});
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
if (state === null) {
runtimeRef.current?.renderSnapshot({ state: null });
} else if (isTerminalState(state)) {
runtimeRef.current?.renderSnapshot({ state });
}
},
paste: (...args) => {
const text = args[0];
@@ -283,7 +299,12 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
replay: (event) => runtimeRef.current?.replay(event),
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => {
pasteText(text);
},
@@ -450,6 +471,7 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
+43 -5
View File
@@ -15,6 +15,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
@@ -59,6 +60,7 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
@@ -86,6 +88,7 @@ import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
@@ -313,6 +316,10 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
@@ -376,6 +383,7 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -433,8 +441,16 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalStreamKey],
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
useEffect(() => {
@@ -548,6 +564,7 @@ export function TerminalPane({
);
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS });
});
@@ -587,10 +604,29 @@ export function TerminalPane({
},
);
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
);
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
);
const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
@@ -617,9 +653,8 @@ export function TerminalPane({
client,
getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput,
onReplay: ({ terminalId: id, replay }) => {
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onRestore: handleStreamRestore,
onSnapshot: handleStreamSnapshot,
onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus,
@@ -639,6 +674,8 @@ export function TerminalPane({
getStreamRestoreOptions,
handleStreamControllerStatus,
handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit,
isConnected,
]);
@@ -1179,6 +1216,7 @@ export function TerminalPane({
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
@@ -1,34 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, type StyleProp, type ViewStyle } from "react-native";
/** One looping value per skeleton, shared by its shapes so they pulse together. */
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
/** A placeholder shape; `style` sets its size, radius and color. */
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
@@ -455,6 +455,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
schedule: (task, delayMs) => {
const timeout = setTimeout(task, delayMs);
return () => clearTimeout(timeout);
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
@@ -292,10 +292,7 @@ const CATALOG_DATA = [
version: "0.6.13",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
// `-p <pkg>` names the package and `mcode` names its binary. Without the
// explicit binary, npx resolves the executable from the unscoped package
// name (`code`), which this package does not publish.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
},
{
id: "minion-code",
@@ -22,7 +22,6 @@ import {
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
const TAILSCALE_DOCS_URL = "https://paseo.sh/docs/connectivity#tailscale";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedShieldCheck = withUnistyles(ShieldCheck);
@@ -220,14 +219,7 @@ function RelayConsent(props: PairDeviceBodyProps) {
</View>
<View style={styles.directRow}>
<ThemedNetwork size={14} style={styles.directIcon} />
<View style={styles.directContent}>
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
<ExternalLink
href={TAILSCALE_DOCS_URL}
label={t("pairing.device.tailscaleDocs")}
accessibilityLabel={t("pairing.device.tailscaleDocsAccessibility")}
/>
</View>
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
</View>
</View>
);
@@ -346,12 +338,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
marginTop: 1, // optical: seats the glyph on the hint's first text line
},
directContent: {
flex: 1,
alignItems: "flex-start",
gap: theme.spacing[2],
},
directHint: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
@@ -1,9 +1,39 @@
import { View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
export function PrActivitySkeleton() {
const pulse = useSkeletonPulse();
@@ -2,8 +2,7 @@ import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { PrActivitySkeleton } from "./activity-skeleton";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
@@ -62,9 +62,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
// The package publishes `mcode`, not `code`, so npx needs the package and
// the binary named separately.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
@@ -422,23 +422,6 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
isArchived: true,
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: status,
},
memory: createBaseMemory(),
});
expect(expectReadyState(result.state).sync).toEqual({
status: "sync_error",
isRetrying: status === "retrying",
});
});
it("renders an archived agent before provider history is initialized", () => {
const result = deriveAgentScreenViewState({
input: {
@@ -175,14 +175,10 @@ function resolveAgentScreenSync(args: {
hadInitialSyncFailure: boolean;
}): AgentScreenReadySyncState {
const { input, hadInitialSyncFailure } = args;
if (
input.isArchived &&
input.visibilityCatchUpStatus !== "error" &&
input.visibilityCatchUpStatus !== "retrying"
) {
if (input.isArchived) {
return { status: "idle" };
}
if (!input.isConnected && !input.isArchived) {
if (!input.isConnected) {
return { status: "reconnecting" };
}
if (input.missingAgentState.kind === "error") {
-89
View File
@@ -617,7 +617,6 @@ export const ar: TranslationResources = {
terminal: {
hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
@@ -1641,82 +1640,6 @@ export const ar: TranslationResources = {
},
},
onboarding: {
setup: {
title: "إعداد Paseo",
footnote: "كل ما هنا موجود أيضًا في الإعدادات",
stepCounter: "الخطوة {{current}} من {{total}}",
skip: "تخطي الإعداد",
continue: "متابعة",
finish: "إنهاء",
steps: {
appearance: {
label: "المظهر",
title: "مرحبًا بك في Paseo",
description:
"شغّل Claude Code وCodex ووكلاء آخرين على هذا الكمبيوتر، من مكتبك أو من هاتفك. اختر مظهرًا أولًا.",
},
providers: {
label: "المزوّدون",
title: "وكلاؤك",
description:
"يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر. أوقف ما لا تريده في منتقي الوكلاء.",
},
projects: {
label: "المشاريع",
title: "أضف مشاريعك",
description:
"أضف المجلدات التي تريد أن يعمل فيها الوكلاء. يمكنك إضافة المزيد لاحقًا من الشريط الجانبي.",
addProject: "إضافة مشروع",
addAnotherProject: "إضافة مشروع آخر…",
added: "تمت الإضافة",
suggestions: {
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
stillReading: "ما زلنا نقرأ سجل {{providers}}…",
readFailed: "تعذّرت قراءة سجل {{provider}}",
empty: "لم يتم العثور على مشاريع حديثة",
sessions_one: "جلسة واحدة ({{count}})",
sessions_other: "{{count}} جلسات",
addSelected_one: "إضافة مشروع واحد ({{count}})",
addSelected_other: "إضافة {{count}} مشاريع",
adding: "جارٍ الإضافة…",
addFailed: "تعذّرت إضافة هذا المجلد",
},
},
orchestration: {
label: "التنسيق",
title: "اسمح للوكلاء باستخدام Paseo",
description:
"باستخدام أدوات Paseo، يمكن للوكيل تسليم العمل لوكلاء آخرين وإنشاء مساحات العمل والجداول بنفسه.",
capabilities: {
agents: {
title: "تشغيل وكلاء آخرين وتوجيههم",
description:
"شغّل وكلاء Codex أو Claude Code أو OpenCode وأرسل لهم الطلبات وانتظر نتائجهم.",
},
workspaces: {
title: "إنشاء مساحات عمل",
description: "افتح worktree لكل مهمة حتى لا يتعارض الوكلاء العاملون بالتوازي.",
},
schedules: {
title: "الجداول ونبضات المتابعة",
description: "تحقق من CI كل 10 دقائق وأصلح الإخفاقات.",
},
terminals: {
title: "الطرفيات والسكربتات",
description: "شغّل خادم التطوير لمساحة العمل واقرأ مخرجاته.",
},
},
},
phone: {
label: "الهاتف",
title: "خذ وكلاءك معك",
description:
"يتصل Paseo لنظامي iOS وAndroid بهذا الكمبيوتر. يواصل الوكلاء العمل هنا، ويتابعهم هاتفك ويوافق على الأذونات ويرسل الطلبات.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "مرحبا بكم في Paseo",
subtitle: "قم بتوصيل جهاز الكمبيوتر الخاص بك للبدء",
actions: {
@@ -1935,8 +1858,6 @@ export const ar: TranslationResources = {
notNow: "ليس الآن",
directConnectionHint:
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
tailscaleDocs: "إعداد Tailscale",
tailscaleDocsAccessibility: "اقرأ كيفية الاتصال عبر Tailscale",
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
@@ -2170,11 +2091,6 @@ export const ar: TranslationResources = {
},
},
providerUsage: {
checkUsage: "التحقق من الاستخدام",
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
off: "التحقق من الاستخدام متوقف على {{host}}.",
turnOn: "تشغيل",
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
@@ -2436,11 +2352,6 @@ export const ar: TranslationResources = {
general: {
title: "عام",
sending: "الإرسال",
setup: {
label: "الإعداد الأولي",
description: "أعد المرور على المظهر والمزوّدين والمشاريع وإقران الهاتف",
action: "إظهار الإعداد مجددًا",
},
browserData: {
title: "بيانات المتصفح",
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
-89
View File
@@ -614,7 +614,6 @@ export const en = {
terminal: {
hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.",
restoreRequiresUpdate: "Update the host to view terminals.",
unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}",
@@ -1666,82 +1665,6 @@ export const en = {
},
},
onboarding: {
setup: {
title: "Set up Paseo",
footnote: "Everything here is also in Settings",
stepCounter: "Step {{current}} of {{total}}",
skip: "Skip setup",
continue: "Continue",
finish: "Finish",
steps: {
appearance: {
label: "Appearance",
title: "Welcome to Paseo",
description:
"Run Claude Code, Codex and other agents on this computer, from your desk or your phone. First, pick a look.",
},
providers: {
label: "Providers",
title: "Your agents",
description:
"Paseo runs the agent CLIs already on this computer. Turn off any you don't want in the agent picker.",
},
projects: {
label: "Projects",
title: "Add your projects",
description:
"Add the folders you want agents to work in. You can add more later from the sidebar.",
addProject: "Add project",
addAnotherProject: "Add another project…",
added: "Added",
suggestions: {
loading: "Looking for folders you've worked in",
stillReading: "Still reading {{providers}} history…",
readFailed: "Couldn't read {{provider}} history",
empty: "No recent projects found",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
addSelected_one: "Add {{count}} project",
addSelected_other: "Add {{count}} projects",
adding: "Adding…",
addFailed: "Couldn't add this folder",
},
},
orchestration: {
label: "Orchestration",
title: "Let agents use Paseo",
description:
"With Paseo tools, an agent can hand work to other agents and set up its own workspaces and schedules.",
capabilities: {
agents: {
title: "Start and steer other agents",
description:
"Launch Codex, Claude Code or OpenCode agents, send them prompts and wait for their results.",
},
workspaces: {
title: "Create workspaces",
description: "Open a worktree for each task so parallel agents don't collide.",
},
schedules: {
title: "Schedules and heartbeats",
description: "Check CI every 10 minutes and fix failures.",
},
terminals: {
title: "Terminals and scripts",
description: "Run your workspace's dev server and read its output.",
},
},
},
phone: {
label: "Phone",
title: "Take your agents with you",
description:
"Paseo for iOS and Android connects to this computer. Agents keep running here; your phone watches them, approves permissions and sends prompts.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Welcome to Paseo",
subtitle: "Connect your computer to get started",
actions: {
@@ -1960,8 +1883,6 @@ export const en = {
notNow: "Not now",
directConnectionHint:
"Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.",
tailscaleDocs: "Set up Tailscale",
tailscaleDocsAccessibility: "Read how to connect over Tailscale",
updateRequired: "Update the host to enable relay from Paseo Desktop.",
unavailable: "Pairing offer unavailable.",
hint: "Scan this QR code with Paseo on your phone, or copy the link below.",
@@ -2195,11 +2116,6 @@ export const en = {
},
},
providerUsage: {
checkUsage: "Check usage",
checkUsageHint: "Applies to {{host}} on every device",
off: "Usage is off on {{host}}.",
turnOn: "Turn on",
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
@@ -2525,11 +2441,6 @@ export const en = {
general: {
title: "General",
sending: "Sending",
setup: {
label: "First-run setup",
description: "Walk through appearance, providers, projects and phone pairing again",
action: "Show setup again",
},
browserData: {
title: "Browser data",
siteData: "Cookies and site data",
-92
View File
@@ -623,7 +623,6 @@ export const es: TranslationResources = {
terminal: {
hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
restoreRequiresUpdate: "Actualiza el host para ver las terminales.",
unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
@@ -1687,84 +1686,6 @@ export const es: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Configurar Paseo",
footnote: "Todo lo de aquí también está en Ajustes",
stepCounter: "Paso {{current}} de {{total}}",
skip: "Omitir configuración",
continue: "Continuar",
finish: "Finalizar",
steps: {
appearance: {
label: "Apariencia",
title: "Te damos la bienvenida a Paseo",
description:
"Ejecuta Claude Code, Codex y otros agentes en este ordenador, desde tu escritorio o tu teléfono. Primero, elige un aspecto.",
},
providers: {
label: "Proveedores",
title: "Tus agentes",
description:
"Paseo usa las CLI de agentes que ya están en este ordenador. Desactiva las que no quieras ver en el selector de agentes.",
},
projects: {
label: "Proyectos",
title: "Añade tus proyectos",
description:
"Añade las carpetas en las que quieres que trabajen los agentes. Puedes añadir más después desde la barra lateral.",
addProject: "Añadir proyecto",
addAnotherProject: "Añadir otro proyecto…",
added: "Añadido",
suggestions: {
loading: "Buscando carpetas en las que has trabajado",
stillReading: "Todavía leyendo el historial de {{providers}}…",
readFailed: "No se pudo leer el historial de {{provider}}",
empty: "No se encontraron proyectos recientes",
sessions_one: "{{count}} sesión",
sessions_other: "{{count}} sesiones",
addSelected_one: "Añadir {{count}} proyecto",
addSelected_other: "Añadir {{count}} proyectos",
adding: "Añadiendo…",
addFailed: "No se pudo añadir esta carpeta",
},
},
orchestration: {
label: "Orquestación",
title: "Deja que los agentes usen Paseo",
description:
"Con las herramientas de Paseo, un agente puede pasar trabajo a otros agentes y crear sus propios espacios de trabajo y programaciones.",
capabilities: {
agents: {
title: "Iniciar y dirigir otros agentes",
description:
"Lanza agentes de Codex, Claude Code u OpenCode, envíales instrucciones y espera sus resultados.",
},
workspaces: {
title: "Crear espacios de trabajo",
description:
"Abre un worktree por tarea para que los agentes en paralelo no choquen.",
},
schedules: {
title: "Programaciones y heartbeats",
description: "Revisa la CI cada 10 minutos y corrige los fallos.",
},
terminals: {
title: "Terminales y scripts",
description:
"Ejecuta el servidor de desarrollo de tu espacio de trabajo y lee su salida.",
},
},
},
phone: {
label: "Teléfono",
title: "Lleva tus agentes contigo",
description:
"Paseo para iOS y Android se conecta a este ordenador. Los agentes siguen ejecutándose aquí; tu teléfono los supervisa, aprueba permisos y envía instrucciones.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Bienvenido aPaseo",
subtitle: "Conecte su computadora para comenzar",
actions: {
@@ -1985,8 +1906,6 @@ export const es: TranslationResources = {
notNow: "Ahora no",
directConnectionHint:
"Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.",
tailscaleDocs: "Configurar Tailscale",
tailscaleDocsAccessibility: "Leer cómo conectarse con Tailscale",
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
unavailable: "Oferta de maridaje no disponible.",
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.",
@@ -2220,11 +2139,6 @@ export const es: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
off: "El uso está desactivado en {{host}}.",
turnOn: "Activar",
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
@@ -2450,12 +2364,6 @@ export const es: TranslationResources = {
general: {
title: "General",
sending: "Envío",
setup: {
label: "Configuración inicial",
description:
"Vuelve a recorrer apariencia, proveedores, proyectos y emparejamiento del teléfono",
action: "Mostrar configuración de nuevo",
},
browserData: {
title: "Datos del navegador",
siteData: "Cookies y datos de sitios",
-92
View File
@@ -623,7 +623,6 @@ export const fr: TranslationResources = {
terminal: {
hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.",
unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
@@ -1685,84 +1684,6 @@ export const fr: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Configurer Paseo",
footnote: "Tout ceci se trouve aussi dans les Réglages",
stepCounter: "Étape {{current}} sur {{total}}",
skip: "Passer la configuration",
continue: "Continuer",
finish: "Terminer",
steps: {
appearance: {
label: "Apparence",
title: "Bienvenue dans Paseo",
description:
"Lancez Claude Code, Codex et d'autres agents sur cet ordinateur, depuis votre bureau ou votre téléphone. Choisissez d'abord un style.",
},
providers: {
label: "Fournisseurs",
title: "Vos agents",
description:
"Paseo utilise les CLI d'agents déjà installées sur cet ordinateur. Désactivez celles que vous ne voulez pas dans le sélecteur d'agents.",
},
projects: {
label: "Projets",
title: "Ajoutez vos projets",
description:
"Ajoutez les dossiers dans lesquels les agents doivent travailler. Vous pourrez en ajouter d'autres depuis la barre latérale.",
addProject: "Ajouter un projet",
addAnotherProject: "Ajouter un autre projet…",
added: "Ajouté",
suggestions: {
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
stillReading: "Lecture de l'historique {{providers}} en cours…",
readFailed: "Impossible de lire l'historique {{provider}}",
empty: "Aucun projet récent trouvé",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
addSelected_one: "Ajouter {{count}} projet",
addSelected_other: "Ajouter {{count}} projets",
adding: "Ajout…",
addFailed: "Impossible d'ajouter ce dossier",
},
},
orchestration: {
label: "Orchestration",
title: "Laissez les agents utiliser Paseo",
description:
"Avec les outils Paseo, un agent peut confier du travail à d'autres agents et créer ses propres espaces de travail et planifications.",
capabilities: {
agents: {
title: "Lancer et piloter d'autres agents",
description:
"Lancez des agents Codex, Claude Code ou OpenCode, envoyez-leur des instructions et attendez leurs résultats.",
},
workspaces: {
title: "Créer des espaces de travail",
description:
"Ouvrez un worktree par tâche pour que les agents en parallèle ne se gênent pas.",
},
schedules: {
title: "Planifications et heartbeats",
description: "Vérifier la CI toutes les 10 minutes et corriger les échecs.",
},
terminals: {
title: "Terminaux et scripts",
description:
"Lancez le serveur de développement de votre espace de travail et lisez sa sortie.",
},
},
},
phone: {
label: "Téléphone",
title: "Emportez vos agents partout",
description:
"Paseo pour iOS et Android se connecte à cet ordinateur. Les agents continuent de tourner ici ; votre téléphone les suit, approuve les permissions et envoie des instructions.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Bienvenue dans Paseo",
subtitle: "Connectez votre ordinateur pour commencer",
actions: {
@@ -1983,8 +1904,6 @@ export const fr: TranslationResources = {
notNow: "Pas maintenant",
directConnectionHint:
"Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.",
tailscaleDocs: "Configurer Tailscale",
tailscaleDocsAccessibility: "Lire comment se connecter via Tailscale",
updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
unavailable: "Offre d’association indisponible.",
hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.",
@@ -2220,11 +2139,6 @@ export const fr: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Vérifier l’utilisation",
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
off: "L’utilisation est désactivée sur {{host}}.",
turnOn: "Activer",
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
@@ -2450,12 +2364,6 @@ export const fr: TranslationResources = {
general: {
title: "Général",
sending: "Envoi",
setup: {
label: "Configuration initiale",
description:
"Reprendre l'apparence, les fournisseurs, les projets et l'appairage du téléphone",
action: "Afficher la configuration",
},
browserData: {
title: "Données du navigateur",
siteData: "Cookies et données des sites",
-91
View File
@@ -623,7 +623,6 @@ export const ja: TranslationResources = {
terminal: {
hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
@@ -1658,83 +1657,6 @@ export const ja: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo のセットアップ",
footnote: "ここでの設定はすべて設定画面でも変更できます",
stepCounter: "ステップ {{current}}/{{total}}",
skip: "セットアップをスキップ",
continue: "続ける",
finish: "完了",
steps: {
appearance: {
label: "外観",
title: "Paseo へようこそ",
description:
"このコンピューターで Claude Code、Codex などのエージェントを、デスクからでもスマートフォンからでも実行できます。まずは見た目を選びましょう。",
},
providers: {
label: "プロバイダー",
title: "エージェント",
description:
"Paseo はこのコンピューターにインストール済みのエージェント CLI を使います。エージェント選択に表示したくないものはオフにしてください。",
},
projects: {
label: "プロジェクト",
title: "プロジェクトを追加",
description:
"エージェントに作業させるフォルダーを追加します。後からサイドバーで追加することもできます。",
addProject: "プロジェクトを追加",
addAnotherProject: "別のプロジェクトを追加…",
added: "追加済み",
suggestions: {
loading: "作業したフォルダーを探しています",
stillReading: "{{providers}} の履歴を読み込んでいます…",
readFailed: "{{provider}} の履歴を読み込めませんでした",
empty: "最近のプロジェクトは見つかりませんでした",
sessions_one: "{{count}} セッション",
sessions_other: "{{count}} セッション",
addSelected_one: "{{count}} 件のプロジェクトを追加",
addSelected_other: "{{count}} 件のプロジェクトを追加",
adding: "追加しています…",
addFailed: "このフォルダーを追加できませんでした",
},
},
orchestration: {
label: "オーケストレーション",
title: "エージェントに Paseo を使わせる",
description:
"Paseo ツールを使うと、エージェントは他のエージェントに作業を任せたり、自分でワークスペースやスケジュールを作成したりできます。",
capabilities: {
agents: {
title: "他のエージェントを起動・操作",
description:
"Codex、Claude Code、OpenCode のエージェントを起動し、プロンプトを送って結果を待ちます。",
},
workspaces: {
title: "ワークスペースを作成",
description:
"タスクごとに worktree を作成し、並行するエージェント同士の衝突を防ぎます。",
},
schedules: {
title: "スケジュールとハートビート",
description: "10 分ごとに CI を確認して失敗を修正する。",
},
terminals: {
title: "ターミナルとスクリプト",
description: "ワークスペースの開発サーバーを起動し、その出力を読み取ります。",
},
},
},
phone: {
label: "スマートフォン",
title: "エージェントを持ち歩く",
description:
"iOS と Android 版 Paseo はこのコンピューターに接続します。エージェントはここで動き続け、スマートフォンで状況を確認し、権限を承認し、プロンプトを送れます。",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Paseoへようこそ",
subtitle: "始めるにはコンピューターに接続してください",
actions: {
@@ -1955,8 +1877,6 @@ export const ja: TranslationResources = {
notNow: "今はしない",
directConnectionHint:
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
tailscaleDocs: "Tailscale を設定",
tailscaleDocsAccessibility: "Tailscale で接続する方法を読む",
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
@@ -2192,11 +2112,6 @@ export const ja: TranslationResources = {
},
},
providerUsage: {
checkUsage: "使用状況を確認",
checkUsageHint: "すべてのデバイスで {{host}} に適用",
off: "{{host}} の使用状況確認はオフです。",
turnOn: "オンにする",
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
@@ -2422,12 +2337,6 @@ export const ja: TranslationResources = {
general: {
title: "一般",
sending: "送信",
setup: {
label: "初回セットアップ",
description:
"外観、プロバイダー、プロジェクト、スマートフォンのペアリングをもう一度設定します",
action: "セットアップを再表示",
},
browserData: {
title: "ブラウザーデータ",
siteData: "Cookie とサイトデータ",
-89
View File
@@ -618,7 +618,6 @@ export const ko: TranslationResources = {
terminal: {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
@@ -1651,82 +1650,6 @@ export const ko: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo 설정",
footnote: "여기의 모든 항목은 설정에서도 변경할 수 있습니다",
stepCounter: "{{total}}단계 중 {{current}}단계",
skip: "설정 건너뛰기",
continue: "계속",
finish: "완료",
steps: {
appearance: {
label: "모양",
title: "Paseo에 오신 것을 환영합니다",
description:
"이 컴퓨터에서 Claude Code, Codex 등 여러 에이전트를 책상에서도, 휴대폰에서도 실행하세요. 먼저 모양을 고르세요.",
},
providers: {
label: "제공자",
title: "에이전트",
description:
"Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다. 에이전트 선택기에 표시하고 싶지 않은 항목은 끄세요.",
},
projects: {
label: "프로젝트",
title: "프로젝트 추가",
description:
"에이전트가 작업할 폴더를 추가하세요. 나중에 사이드바에서 더 추가할 수 있습니다.",
addProject: "프로젝트 추가",
addAnotherProject: "다른 프로젝트 추가…",
added: "추가됨",
suggestions: {
loading: "작업했던 폴더를 찾는 중",
stillReading: "{{providers}} 기록을 아직 읽는 중…",
readFailed: "{{provider}} 기록을 읽지 못했습니다",
empty: "최근 프로젝트를 찾지 못했습니다",
sessions_one: "세션 {{count}}개",
sessions_other: "세션 {{count}}개",
addSelected_one: "프로젝트 {{count}}개 추가",
addSelected_other: "프로젝트 {{count}}개 추가",
adding: "추가하는 중…",
addFailed: "이 폴더를 추가하지 못했습니다",
},
},
orchestration: {
label: "오케스트레이션",
title: "에이전트가 Paseo를 사용하도록 허용",
description:
"Paseo 도구를 사용하면 에이전트가 다른 에이전트에게 작업을 넘기고 직접 워크스페이스와 일정을 만들 수 있습니다.",
capabilities: {
agents: {
title: "다른 에이전트 시작 및 조정",
description:
"Codex, Claude Code, OpenCode 에이전트를 시작하고 프롬프트를 보낸 뒤 결과를 기다립니다.",
},
workspaces: {
title: "워크스페이스 만들기",
description: "작업마다 worktree를 열어 병렬 에이전트끼리 충돌하지 않게 합니다.",
},
schedules: {
title: "일정과 하트비트",
description: "10분마다 CI를 확인하고 실패를 수정합니다.",
},
terminals: {
title: "터미널과 스크립트",
description: "워크스페이스의 개발 서버를 실행하고 출력을 읽습니다.",
},
},
},
phone: {
label: "휴대폰",
title: "어디서나 에이전트와 함께",
description:
"iOS 및 Android용 Paseo가 이 컴퓨터에 연결됩니다. 에이전트는 여기서 계속 실행되고, 휴대폰으로 상태를 보고 권한을 승인하며 프롬프트를 보냅니다.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Paseo에 오신 것을 환영합니다",
subtitle: "시작하려면 컴퓨터를 연결하세요",
actions: {
@@ -1945,8 +1868,6 @@ export const ko: TranslationResources = {
notNow: "지금은 아님",
directConnectionHint:
"릴레이 없이 TCP, Tailscale 또는 다른 VPN을 통해 직접 연결하세요. QR 코드가 생성되지 않습니다.",
tailscaleDocs: "Tailscale 설정",
tailscaleDocsAccessibility: "Tailscale로 연결하는 방법 읽기",
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
unavailable: "페어링 정보를 사용할 수 없습니다.",
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
@@ -2180,11 +2101,6 @@ export const ko: TranslationResources = {
},
},
providerUsage: {
checkUsage: "사용량 확인",
checkUsageHint: "모든 기기에서 {{host}}에 적용",
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
turnOn: "켜기",
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
@@ -2410,11 +2326,6 @@ export const ko: TranslationResources = {
general: {
title: "일반",
sending: "전송",
setup: {
label: "초기 설정",
description: "모양, 제공자, 프로젝트, 휴대폰 페어링을 다시 진행합니다",
action: "설정 다시 보기",
},
browserData: {
title: "브라우저 데이터",
siteData: "쿠키 및 사이트 데이터",
-90
View File
@@ -622,7 +622,6 @@ export const ptBR: TranslationResources = {
terminal: {
hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
restoreRequiresUpdate: "Atualize o host para visualizar os terminais.",
unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
@@ -1672,83 +1671,6 @@ export const ptBR: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Configurar o Paseo",
footnote: "Tudo aqui também está em Configurações",
stepCounter: "Etapa {{current}} de {{total}}",
skip: "Pular configuração",
continue: "Continuar",
finish: "Concluir",
steps: {
appearance: {
label: "Aparência",
title: "Boas-vindas ao Paseo",
description:
"Execute Claude Code, Codex e outros agentes neste computador, da sua mesa ou do seu celular. Primeiro, escolha um visual.",
},
providers: {
label: "Provedores",
title: "Seus agentes",
description:
"O Paseo usa as CLIs de agentes já instaladas neste computador. Desative as que você não quer no seletor de agentes.",
},
projects: {
label: "Projetos",
title: "Adicione seus projetos",
description:
"Adicione as pastas em que os agentes devem trabalhar. Você pode adicionar mais depois pela barra lateral.",
addProject: "Adicionar projeto",
addAnotherProject: "Adicionar outro projeto…",
added: "Adicionado",
suggestions: {
loading: "Procurando pastas em que você trabalhou",
stillReading: "Ainda lendo o histórico de {{providers}}…",
readFailed: "Não foi possível ler o histórico de {{provider}}",
empty: "Nenhum projeto recente encontrado",
sessions_one: "{{count}} sessão",
sessions_other: "{{count}} sessões",
addSelected_one: "Adicionar {{count}} projeto",
addSelected_other: "Adicionar {{count}} projetos",
adding: "Adicionando…",
addFailed: "Não foi possível adicionar esta pasta",
},
},
orchestration: {
label: "Orquestração",
title: "Deixe os agentes usarem o Paseo",
description:
"Com as ferramentas do Paseo, um agente pode passar trabalho para outros agentes e criar seus próprios workspaces e agendamentos.",
capabilities: {
agents: {
title: "Iniciar e guiar outros agentes",
description:
"Inicie agentes do Codex, Claude Code ou OpenCode, envie prompts e aguarde os resultados.",
},
workspaces: {
title: "Criar workspaces",
description:
"Abra um worktree por tarefa para que agentes em paralelo não entrem em conflito.",
},
schedules: {
title: "Agendamentos e heartbeats",
description: "Verificar a CI a cada 10 minutos e corrigir falhas.",
},
terminals: {
title: "Terminais e scripts",
description: "Execute o servidor de desenvolvimento do workspace e leia a saída.",
},
},
},
phone: {
label: "Celular",
title: "Leve seus agentes com você",
description:
"O Paseo para iOS e Android se conecta a este computador. Os agentes continuam rodando aqui; seu celular os acompanha, aprova permissões e envia prompts.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Bem-vindo ao Paseo",
subtitle: "Conecte seu computador para começar",
actions: {
@@ -1969,8 +1891,6 @@ export const ptBR: TranslationResources = {
notNow: "Agora não",
directConnectionHint:
"Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.",
tailscaleDocs: "Configurar Tailscale",
tailscaleDocsAccessibility: "Ler como conectar pelo Tailscale",
updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.",
unavailable: "Oferta de pareamento indisponível.",
hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.",
@@ -2204,11 +2124,6 @@ export const ptBR: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
off: "O uso está desativado em {{host}}.",
turnOn: "Ativar",
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
@@ -2434,11 +2349,6 @@ export const ptBR: TranslationResources = {
general: {
title: "Geral",
sending: "Envio",
setup: {
label: "Configuração inicial",
description: "Rever aparência, provedores, projetos e pareamento do celular",
action: "Mostrar configuração novamente",
},
browserData: {
title: "Dados do navegador",
siteData: "Cookies e dados de sites",
-90
View File
@@ -624,7 +624,6 @@ export const ru: TranslationResources = {
terminal: {
hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
@@ -1670,83 +1669,6 @@ export const ru: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Настройка Paseo",
footnote: "Всё это также есть в настройках",
stepCounter: "Шаг {{current}} из {{total}}",
skip: "Пропустить настройку",
continue: "Продолжить",
finish: "Готово",
steps: {
appearance: {
label: "Оформление",
title: "Добро пожаловать в Paseo",
description:
"Запускайте Claude Code, Codex и других агентов на этом компьютере — за столом или с телефона. Сначала выберите оформление.",
},
providers: {
label: "Провайдеры",
title: "Ваши агенты",
description:
"Paseo использует CLI агентов, уже установленные на этом компьютере. Отключите те, которые не нужны в списке агентов.",
},
projects: {
label: "Проекты",
title: "Добавьте проекты",
description:
"Добавьте папки, в которых будут работать агенты. Позже можно добавить ещё из боковой панели.",
addProject: "Добавить проект",
addAnotherProject: "Добавить другой проект…",
added: "Добавлено",
suggestions: {
loading: "Ищем папки, в которых вы работали",
stillReading: "Ещё читаем историю {{providers}}…",
readFailed: "Не удалось прочитать историю {{provider}}",
empty: "Недавних проектов не найдено",
sessions_one: "{{count}} сессия",
sessions_other: "Сессий: {{count}}",
addSelected_one: "Добавить {{count}} проект",
addSelected_other: "Добавить проекты: {{count}}",
adding: "Добавляем…",
addFailed: "Не удалось добавить эту папку",
},
},
orchestration: {
label: "Оркестрация",
title: "Дайте агентам доступ к Paseo",
description:
"С инструментами Paseo агент может передавать работу другим агентам и сам создавать рабочие пространства и расписания.",
capabilities: {
agents: {
title: "Запуск других агентов и управление ими",
description:
"Запускайте агентов Codex, Claude Code или OpenCode, отправляйте им запросы и ждите результатов.",
},
workspaces: {
title: "Создание рабочих пространств",
description:
"Отдельный worktree для каждой задачи, чтобы параллельные агенты не мешали друг другу.",
},
schedules: {
title: "Расписания и heartbeat",
description: "Проверять CI каждые 10 минут и исправлять сбои.",
},
terminals: {
title: "Терминалы и скрипты",
description: "Запускайте dev-сервер рабочего пространства и читайте его вывод.",
},
},
},
phone: {
label: "Телефон",
title: "Агенты всегда под рукой",
description:
"Paseo для iOS и Android подключается к этому компьютеру. Агенты продолжают работать здесь, а телефон следит за ними, одобряет разрешения и отправляет запросы.",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "Добро пожаловать в Paseo",
subtitle: "Подключите компьютер, чтобы начать",
actions: {
@@ -1968,8 +1890,6 @@ export const ru: TranslationResources = {
notNow: "Не сейчас",
directConnectionHint:
"Без ретранслятора подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код создаваться не будет.",
tailscaleDocs: "Настроить Tailscale",
tailscaleDocsAccessibility: "Узнать, как подключиться через Tailscale",
updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.",
unavailable: "Данные для сопряжения недоступны.",
hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.",
@@ -2204,11 +2124,6 @@ export const ru: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Проверять использование",
checkUsageHint: "Применяется к {{host}} на всех устройствах",
off: "Проверка использования отключена на {{host}}.",
turnOn: "Включить",
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
@@ -2436,11 +2351,6 @@ export const ru: TranslationResources = {
general: {
title: "Основные",
sending: "Отправка",
setup: {
label: "Первоначальная настройка",
description: "Снова пройти оформление, провайдеры, проекты и привязку телефона",
action: "Показать настройку снова",
},
browserData: {
title: "Данные браузера",
siteData: "Файлы cookie и данные сайтов",
-86
View File
@@ -617,7 +617,6 @@ export const zhCN: TranslationResources = {
terminal: {
hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。",
restoreRequiresUpdate: "更新主机以查看终端。",
unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
@@ -1624,79 +1623,6 @@ export const zhCN: TranslationResources = {
},
},
onboarding: {
setup: {
title: "设置 Paseo",
footnote: "这里的所有选项也都在设置中",
stepCounter: "第 {{current}} 步,共 {{total}} 步",
skip: "跳过设置",
continue: "继续",
finish: "完成",
steps: {
appearance: {
label: "外观",
title: "欢迎使用 Paseo",
description:
"在这台电脑上运行 Claude Code、Codex 和其他代理,无论在桌前还是用手机。先选一个外观。",
},
providers: {
label: "提供方",
title: "你的代理",
description: "Paseo 使用这台电脑上已安装的代理 CLI。关闭不想在代理选择器中显示的项。",
},
projects: {
label: "项目",
title: "添加项目",
description: "添加你希望代理工作的文件夹。之后也可以从侧边栏添加更多。",
addProject: "添加项目",
addAnotherProject: "添加其他项目…",
added: "已添加",
suggestions: {
loading: "正在查找你工作过的文件夹",
stillReading: "仍在读取 {{providers}} 历史记录…",
readFailed: "无法读取 {{provider}} 历史记录",
empty: "未找到最近的项目",
sessions_one: "{{count}} 个会话",
sessions_other: "{{count}} 个会话",
addSelected_one: "添加 {{count}} 个项目",
addSelected_other: "添加 {{count}} 个项目",
adding: "正在添加…",
addFailed: "无法添加此文件夹",
},
},
orchestration: {
label: "编排",
title: "让代理使用 Paseo",
description:
"启用 Paseo 工具后,代理可以把工作交给其他代理,并自行创建工作区和计划任务。",
capabilities: {
agents: {
title: "启动并引导其他代理",
description: "启动 Codex、Claude Code 或 OpenCode 代理,向它们发送提示并等待结果。",
},
workspaces: {
title: "创建工作区",
description: "为每个任务打开一个 worktree,避免并行代理相互冲突。",
},
schedules: {
title: "计划任务和心跳",
description: "每 10 分钟检查一次 CI 并修复失败。",
},
terminals: {
title: "终端和脚本",
description: "运行工作区的开发服务器并读取输出。",
},
},
},
phone: {
label: "手机",
title: "随身带上你的代理",
description:
"iOS 和 Android 版 Paseo 会连接到这台电脑。代理继续在这里运行,手机用来查看进度、批准权限和发送提示。",
appStore: "App Store",
googlePlay: "Google Play",
},
},
},
title: "欢迎使用 Paseo",
subtitle: "连接你的电脑即可开始",
actions: {
@@ -1911,8 +1837,6 @@ export const zhCN: TranslationResources = {
notNow: "暂不",
directConnectionHint:
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
tailscaleDocs: "设置 Tailscale",
tailscaleDocsAccessibility: "了解如何通过 Tailscale 连接",
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
@@ -2145,11 +2069,6 @@ export const zhCN: TranslationResources = {
},
},
providerUsage: {
checkUsage: "检查用量",
checkUsageHint: "在所有设备上应用于 {{host}}",
off: "{{host}} 的用量检查已关闭。",
turnOn: "开启",
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
@@ -2374,11 +2293,6 @@ export const zhCN: TranslationResources = {
general: {
title: "通用",
sending: "发送",
setup: {
label: "首次设置",
description: "重新设置外观、提供方、项目和手机配对",
action: "再次显示设置",
},
browserData: {
title: "浏览器数据",
siteData: "Cookie 和网站数据",
-9
View File
@@ -1,9 +0,0 @@
/**
* First-run setup: a one-time modal that walks a new user through the surfaces Settings
* already has — appearance, providers, projects, Paseo tools and phone pairing.
*
* The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has,
* and how completion is remembered stay inside; import from `@/onboarding` only.
*/
export { OnboardingHost } from "./onboarding-host";
export { useOpenOnboarding } from "./use-open-onboarding";
@@ -1,25 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import type { OnboardingCompletionStorage } from "./model";
export const ONBOARDING_STORAGE_KEY = "@paseo:onboarding";
const OnboardingRecordSchema = z.object({ completedAt: z.string() });
export const asyncStorageOnboardingCompletion: OnboardingCompletionStorage = {
async isCompleted() {
const record = await readValidatedJson(
AsyncStorage,
ONBOARDING_STORAGE_KEY,
OnboardingRecordSchema,
);
return record !== null;
},
async markCompleted() {
await AsyncStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ completedAt: new Date().toISOString() }),
);
},
};
@@ -1,202 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createOnboardingModel,
type OnboardingCompletionStorage,
type OnboardingModel,
type OnboardingSetupHost,
} from "./model";
class MemoryCompletionStorage implements OnboardingCompletionStorage {
completed: boolean;
constructor(completed = false) {
this.completed = completed;
}
async isCompleted(): Promise<boolean> {
return this.completed;
}
async markCompleted(): Promise<void> {
this.completed = true;
}
}
const FRESH_HOST: OnboardingSetupHost = {
serverId: "local",
isConnected: true,
hasHydratedWorkspaces: true,
hasProjects: false,
};
/** Presses Continue on whatever step is showing. */
function advance(model: OnboardingModel) {
const state = model.getState();
if (state.status !== "open") throw new Error("setup is not open");
model.next(state.visit);
}
function currentVisit(model: OnboardingModel): number {
const state = model.getState();
if (state.status !== "open") throw new Error("setup is not open");
return state.visit;
}
async function openModel(storage = new MemoryCompletionStorage()) {
const model = createOnboardingModel({ storage });
await model.ready;
return model;
}
describe("onboarding model", () => {
it("opens on the appearance step for a connected host with no projects", async () => {
const model = await openModel();
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(model.getState()).toMatchObject({
status: "open",
serverId: "local",
steps: ["appearance", "providers", "projects", "orchestration", "phone"],
stepIndex: 0,
});
});
it("waits until the host is connected and its workspaces are hydrated", async () => {
const model = await openModel();
model.applySetupHost(null, { includePhoneStep: true });
model.applySetupHost({ ...FRESH_HOST, isConnected: false }, { includePhoneStep: true });
model.applySetupHost(
{ ...FRESH_HOST, hasHydratedWorkspaces: false },
{ includePhoneStep: true },
);
expect(model.getState()).toEqual({ status: "deciding" });
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(model.getState().status).toBe("open");
});
it("stays hidden for a host that already has projects, even after they are removed", async () => {
const model = await openModel();
model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true });
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(model.getState()).toEqual({ status: "hidden" });
});
it("stays hidden once setup was completed on this client", async () => {
const model = await openModel(new MemoryCompletionStorage(true));
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(model.getState()).toEqual({ status: "hidden" });
});
it("decides only after the completion record has loaded", async () => {
const model = createOnboardingModel({ storage: new MemoryCompletionStorage(true) });
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(model.getState()).toEqual({ status: "deciding" });
await model.ready;
expect(model.getState()).toEqual({ status: "hidden" });
});
it("keeps the modal open when the host gains its first project during setup", async () => {
const model = await openModel();
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
advance(model);
advance(model);
model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true });
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
});
it("omits the phone step when the client is a phone", async () => {
const model = await openModel();
model.applySetupHost(FRESH_HOST, { includePhoneStep: false });
expect(model.getState()).toMatchObject({
steps: ["appearance", "providers", "projects", "orchestration"],
});
});
it("moves between steps without leaving the step list", async () => {
const model = await openModel();
model.applySetupHost(FRESH_HOST, { includePhoneStep: false });
model.back();
expect(model.getState()).toMatchObject({ stepIndex: 0 });
advance(model);
advance(model);
advance(model);
advance(model);
expect(model.getState()).toMatchObject({ stepIndex: 3 });
model.goTo("providers");
expect(model.getState()).toMatchObject({ stepIndex: 1 });
model.back();
expect(model.getState()).toMatchObject({ stepIndex: 0 });
});
it("ignores a late advance from a step visit the user already left", async () => {
const model = await openModel(new MemoryCompletionStorage(true));
model.reopen("local", { includePhoneStep: true });
model.goTo("projects");
// Projects are still being added when the user leaves Projects and comes back.
const addingFrom = currentVisit(model);
model.goTo("appearance");
model.goTo("projects");
model.next(addingFrom);
expect(model.getState()).toMatchObject({ stepIndex: 2 });
// Or closes setup, opens it again and returns to Projects before the add finishes.
await model.complete();
model.reopen("local", { includePhoneStep: true });
model.goTo("projects");
model.next(addingFrom);
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
});
it("records completion when setup closes, so it does not open again", async () => {
const storage = new MemoryCompletionStorage();
const model = await openModel(storage);
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
await model.complete();
expect(model.getState()).toEqual({ status: "hidden" });
expect(storage.completed).toBe(true);
const relaunched = await openModel(storage);
relaunched.applySetupHost(FRESH_HOST, { includePhoneStep: true });
expect(relaunched.getState()).toEqual({ status: "hidden" });
});
it("reopens on the first step for an existing user", async () => {
const model = await openModel(new MemoryCompletionStorage(true));
model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true });
model.reopen("local", { includePhoneStep: true });
expect(model.getState()).toMatchObject({ status: "open", serverId: "local", stepIndex: 0 });
});
it("notifies subscribers on every state change", async () => {
const model = await openModel();
const seen: string[] = [];
model.subscribe(() => {
const state = model.getState();
seen.push(state.status === "open" ? `open:${state.stepIndex}` : state.status);
});
model.applySetupHost(FRESH_HOST, { includePhoneStep: true });
advance(model);
await model.complete();
expect(seen).toEqual(["open:0", "open:1", "hidden"]);
});
});
@@ -1,165 +0,0 @@
export type OnboardingStepId = "appearance" | "providers" | "projects" | "orchestration" | "phone";
const STEPS_WITHOUT_PHONE: readonly OnboardingStepId[] = [
"appearance",
"providers",
"projects",
"orchestration",
];
const STEPS_WITH_PHONE: readonly OnboardingStepId[] = [...STEPS_WITHOUT_PHONE, "phone"];
export interface OnboardingCompletionStorage {
isCompleted(): Promise<boolean>;
markCompleted(): Promise<void>;
}
/** The host setup runs against: the desktop's managed daemon, otherwise the first saved host. */
export interface OnboardingSetupHost {
serverId: string;
isConnected: boolean;
hasHydratedWorkspaces: boolean;
hasProjects: boolean;
}
export interface OnboardingStepOptions {
/** False when the client is itself a phone, where pairing a phone makes no sense. */
includePhoneStep: boolean;
}
export type OnboardingState =
| { status: "deciding" }
| { status: "hidden" }
| {
status: "open";
serverId: string;
steps: readonly OnboardingStepId[];
stepIndex: number;
/** Identifies this stay on the current step; it changes on every move, close or reopen. */
visit: number;
};
export interface OnboardingModel {
/** Resolves once the completion record has loaded. */
ready: Promise<void>;
getState(): OnboardingState;
subscribe(listener: () => void): () => void;
applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void;
reopen(serverId: string, options: OnboardingStepOptions): void;
/**
* Moves on from the step visit the caller saw. A no-op once that visit ended, so an action that
* finishes late never moves a step the user left or a setup they closed and reopened.
*/
next(visit: number): void;
back(): void;
goTo(step: OnboardingStepId): void;
/** Closes setup, whether the user finished or skipped it, and remembers that on this client. */
complete(): Promise<void>;
}
type Completion = "loading" | "completed" | "pending";
interface PendingHost {
host: OnboardingSetupHost | null;
options: OnboardingStepOptions;
}
function isReadyForSetup(host: OnboardingSetupHost | null): host is OnboardingSetupHost {
return host !== null && host.isConnected && host.hasHydratedWorkspaces;
}
export function createOnboardingModel(input: {
storage: OnboardingCompletionStorage;
}): OnboardingModel {
const listeners = new Set<() => void>();
let state: OnboardingState = { status: "deciding" };
let completion: Completion = "loading";
let pendingHost: PendingHost | null = null;
let visits = 0;
function openState(serverId: string, options: OnboardingStepOptions): OnboardingState {
visits += 1;
return {
status: "open",
serverId,
steps: options.includePhoneStep ? STEPS_WITH_PHONE : STEPS_WITHOUT_PHONE,
stepIndex: 0,
visit: visits,
};
}
function setState(next: OnboardingState) {
state = next;
for (const listener of listeners) {
listener();
}
}
// The gate is decided once per launch: a host that later loses its projects, or a
// project added during setup, never flips the decision.
function decide() {
if (state.status !== "deciding" || completion === "loading") return;
if (completion === "completed") {
setState({ status: "hidden" });
return;
}
const host = pendingHost?.host ?? null;
if (!pendingHost || !isReadyForSetup(host)) return;
setState(
host.hasProjects ? { status: "hidden" } : openState(host.serverId, pendingHost.options),
);
}
function moveTo(stepIndex: number) {
if (state.status !== "open") return;
const clamped = Math.max(0, Math.min(stepIndex, state.steps.length - 1));
if (clamped === state.stepIndex) return;
visits += 1;
setState({ ...state, stepIndex: clamped, visit: visits });
}
async function loadCompletion(): Promise<void> {
try {
completion = (await input.storage.isCompleted()) ? "completed" : "pending";
} catch {
// An unreadable record must not trap an existing user in setup.
completion = "completed";
}
decide();
}
const ready = loadCompletion();
return {
ready,
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
applySetupHost(host, options) {
pendingHost = { host, options };
decide();
},
reopen(serverId, options) {
setState(openState(serverId, options));
},
next(visit) {
if (state.status === "open" && state.visit === visit) moveTo(state.stepIndex + 1);
},
back() {
if (state.status === "open") moveTo(state.stepIndex - 1);
},
goTo(step) {
if (state.status !== "open") return;
const stepIndex = state.steps.indexOf(step);
if (stepIndex >= 0) moveTo(stepIndex);
},
async complete() {
completion = "completed";
setState({ status: "hidden" });
await input.storage.markCompleted();
},
};
}
@@ -1,10 +0,0 @@
import { asyncStorageOnboardingCompletion } from "./completion-storage";
import { createOnboardingModel, type OnboardingModel } from "./model";
let instance: OnboardingModel | null = null;
/** The app's one setup model, created on first use so storage is read once per launch. */
export function getOnboardingModel(): OnboardingModel {
instance ??= createOnboardingModel({ storage: asyncStorageOnboardingCompletion });
return instance;
}
@@ -1,343 +0,0 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ScrollView } from "@/components/ui/scroll-view";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions";
import type { Theme } from "@/styles/theme";
import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model";
import { OnboardingStepContent } from "./steps";
type OpenState = Extract<OnboardingState, { status: "open" }>;
const ThemedCheck = withUnistyles(Check);
const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
export function OnboardingModal({ model, state }: { model: OnboardingModel; state: OpenState }) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const step = state.steps[state.stepIndex];
const isFirst = state.stepIndex === 0;
const isLast = state.stepIndex === state.steps.length - 1;
// Held here so Continue can add the checked folders; reading history from the first step means
// the Projects step rarely waits.
const projectSuggestions = useProjectSuggestions(state.serverId);
const { addChecked } = projectSuggestions;
const handleComplete = useCallback(() => {
void model.complete();
}, [model]);
const handleBack = useCallback(() => model.back(), [model]);
const { visit } = state;
const handleNext = useCallback(() => model.next(visit), [model, visit]);
const handleAddSuggestions = useCallback(() => {
void (async () => {
if (await addChecked()) model.next(visit);
})();
}, [addChecked, model, visit]);
const primary = usePrimaryAction({
step,
isLast,
projectSuggestions,
onFinish: handleComplete,
onNext: handleNext,
onAddSuggestions: handleAddSuggestions,
});
const header = useMemo(
() => ({
title: t("onboarding.setup.title"),
leading: <PaseoLogo size={20} />,
subtitle: isCompact ? (
<Text style={styles.stepCounter}>
{t("onboarding.setup.stepCounter", {
current: state.stepIndex + 1,
total: state.steps.length,
})}
</Text>
) : undefined,
}),
[isCompact, state.stepIndex, state.steps.length, t],
);
const footer = (
<>
<View>
{isLast ? null : (
<Button variant="ghost" onPress={handleComplete}>
{t("onboarding.setup.skip")}
</Button>
)}
</View>
<View style={styles.footerActions}>
{isFirst ? null : (
<Button variant="secondary" onPress={handleBack}>
{t("common.actions.back")}
</Button>
)}
<Button variant="default" onPress={primary.onPress} loading={primary.busy}>
{primary.label}
</Button>
</View>
</>
);
return (
<AdaptiveModalSheet
visible
onClose={handleComplete}
header={header}
footer={footer}
scrollable={false}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={880}
desktopHeight={640}
contentStyle={styles.content}
testID="onboarding-modal"
>
<View style={styles.body}>
{isCompact ? null : <StepRail state={state} onSelect={model.goTo} />}
<ScrollView style={styles.pane}>
{/* Padding lives on a wrapper: the ScrollView's contentContainerStyle skips Unistyles. */}
<View style={styles.paneContent}>
<View style={styles.stepHeader}>
<Text style={styles.stepTitle} accessibilityRole="header">
{t(`onboarding.setup.steps.${step}.title`)}
</Text>
<Text style={styles.stepDescription}>
{t(`onboarding.setup.steps.${step}.description`)}
</Text>
</View>
<OnboardingStepContent
step={step}
serverId={state.serverId}
projectSuggestions={projectSuggestions}
onFinish={handleComplete}
/>
</View>
</ScrollView>
</View>
</AdaptiveModalSheet>
);
}
/**
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
* every one was added; otherwise it continues, or finishes on the last step.
*/
function usePrimaryAction(input: {
step: OnboardingStepId;
isLast: boolean;
projectSuggestions: ProjectSuggestionsController;
onFinish: () => void;
onNext: () => void;
onAddSuggestions: () => void;
}): { label: string; busy: boolean; onPress: () => void } {
const { t } = useTranslation();
const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input;
const { checkedCount, isAdding } = projectSuggestions;
if (isLast) {
return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish };
}
// Rows leave the list as they are added, so the count can reach zero while adding.
if (step === "projects" && (isAdding || checkedCount > 0)) {
return {
label: isAdding
? t("onboarding.setup.steps.projects.suggestions.adding")
: t("onboarding.setup.steps.projects.suggestions.addSelected", { count: checkedCount }),
busy: isAdding,
onPress: onAddSuggestions,
};
}
return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext };
}
function StepRail({
state,
onSelect,
}: {
state: OpenState;
onSelect: (step: OnboardingStepId) => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.rail}>
<View style={styles.railSteps}>
{state.steps.map((step, index) => (
<StepRailItem
key={step}
step={step}
number={index + 1}
current={index === state.stepIndex}
done={index < state.stepIndex}
onSelect={onSelect}
/>
))}
</View>
<Text style={styles.railFootnote}>{t("onboarding.setup.footnote")}</Text>
</View>
);
}
function StepRailItem({
step,
number,
current,
done,
onSelect,
}: {
step: OnboardingStepId;
number: number;
current: boolean;
done: boolean;
onSelect: (step: OnboardingStepId) => void;
}) {
const { t } = useTranslation();
const label = t(`onboarding.setup.steps.${step}.label`);
const handlePress = useCallback(() => onSelect(step), [onSelect, step]);
const accessibilityState = useMemo(() => ({ selected: current }), [current]);
const itemStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.railItem,
hovered && !current && styles.railItemHovered,
current && styles.railItemCurrent,
],
[current],
);
return (
<PressHighlight
style={itemStyle}
onPress={handlePress}
accessibilityRole="tab"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-selected={current}
>
<View style={done || current ? styles.railBadgeActive : styles.railBadge}>
{done ? (
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
) : (
<Text style={current ? styles.railBadgeTextActive : styles.railBadgeText}>{number}</Text>
)}
</View>
<Text style={current ? styles.railLabelCurrent : styles.railLabel} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
const BADGE_SIZE = 20;
const styles = StyleSheet.create((theme) => ({
content: {
padding: 0,
gap: 0,
},
body: {
flex: 1,
minHeight: 0,
flexDirection: "row",
},
rail: {
width: 200,
padding: theme.spacing[3],
justifyContent: "space-between",
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
railSteps: {
gap: theme.spacing[1],
},
railItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
railItemHovered: {
backgroundColor: theme.colors.surface2,
},
railItemCurrent: {
backgroundColor: theme.colors.surface2,
},
railBadge: {
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: BADGE_SIZE / 2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
railBadgeActive: {
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: BADGE_SIZE / 2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
railBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
railBadgeTextActive: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
},
railLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
railLabelCurrent: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
railFootnote: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[2],
},
pane: {
flex: 1,
minHeight: 0,
},
paneContent: {
padding: theme.spacing[6],
gap: theme.spacing[6],
},
stepHeader: {
gap: theme.spacing[2],
},
stepTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.medium,
},
stepDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.5,
},
stepCounter: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
footerActions: {
flexDirection: "row",
gap: theme.spacing[2],
},
}));
@@ -1,5 +0,0 @@
import { ThemePicker } from "@/appearance/theme-picker";
export function AppearanceStep() {
return <ThemePicker />;
}
@@ -1,32 +0,0 @@
import type { ProjectSuggestionsController } from "@/project-suggestions";
import type { OnboardingStepId } from "../model";
import { AppearanceStep } from "./appearance-step";
import { OrchestrationStep } from "./orchestration-step";
import { PhoneStep } from "./phone-step";
import { ProjectsStep } from "./projects-step";
import { ProvidersStep } from "./providers-step";
export function OnboardingStepContent({
step,
serverId,
projectSuggestions,
onFinish,
}: {
step: OnboardingStepId;
serverId: string;
projectSuggestions: ProjectSuggestionsController;
onFinish: () => void;
}) {
switch (step) {
case "appearance":
return <AppearanceStep />;
case "providers":
return <ProvidersStep serverId={serverId} />;
case "projects":
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
case "orchestration":
return <OrchestrationStep serverId={serverId} />;
case "phone":
return <PhoneStep serverId={serverId} onFinish={onFinish} />;
}
}

Some files were not shown because too many files have changed in this diff Show More