Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8c6de842a3 chore(release): cut 0.14.0 2026-10-29 09:11:54 +01:00
Mohamed Boudra 2d97d04805 Publish store screenshots from the repo on each stable release (#8186)
* feat(release): publish store screenshots from the repo

Stable releases now upload the committed store renders: the App Store
review lane attaches the iPhone and iPad sets to the version it submits,
and a new Play lane replaces the phone and tablet screenshots after the
Android submit. F-Droid reads the same Android files, which now link to
the store renders instead of the website images.

The Play lane needs its own copy of the submit service-account key
(GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key
Expo stores for eas submit is not available to custom jobs, and EAS
Metadata does not handle screenshots for either store.

* fix(release): rewrite the Android screenshot links on every render

Google Play and F-Droid read numbered links to the Google renders. The
renderer now rewrites them from the panels it exported, so adding or
renaming a panel reaches all three stores.

(cherry picked from commit 7c88a48be4)
2026-10-29 08:42:59 +01:00
Mohamed Boudra 55c77021da chore(store): add phone and tablet store screenshots (#8099)
Five panels for the App Store and Google Play in iPhone, Android phone,
iPad 13-inch and Android tablet sizes, rendered from native captures of
the real app against a staged demo host.

The renderer keeps the captures and the store art reproducible: it
resizes every capture with Lanczos to the exact pixels it fills because
Chrome's own downscaling made earlier exports soft, and it fails when a
headline or subline wraps differently, because the device is placed from
that fixed text height. The README is the recapture runbook and the
store-screenshots skill points agents to it.

(cherry picked from commit 460ba8e8a3)
2026-10-29 08:42:59 +01:00
Mohamed Boudra 03aedcdd47 docs(changelog): promote the 0.14.0 entry to stable
Rename the series entry to 0.14.0 in English and Chinese, drop the core
Claude entrypoint workaround bullet that #8164 removed, and point to the
optional Claude Code entrypoint plugin.
2026-10-29 06:56:20 +01:00
Mohamed Boudra 869eb5a72f Make Claude session entrypoint relabeling optional (#8164)
* fix(claude): make session entrypoint relabeling opt-in

Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally.

* test(hub): use standard budget for restart and worktree recovery

The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions.

(cherry picked from commit e8201ab66b)
2026-10-29 06:50:14 +01:00
Mohamed Boudra 1a05491be5 chore(release): cut 0.14.0-beta.2 2026-10-28 18:16:49 +01:00
Mohamed Boudra 28bb7e29be docs(changelog): fold 0.14.0-beta.2 UI fixes into the 0.14.0 entry 2026-10-28 17:46:16 +01:00
Mohamed Boudra d6d04b0c56 Show a lone tool call as itself and group from the second call (#8109)
* fix(app): show a lone tool call as itself and group from the second call

A run of one tool call or thought renders as the call. When a second call
arrives the row becomes a group that keeps the first call's id, so the row
key stays put. Tool call and group open state move into a per-agent store:
opening a lone call also opens its future group without notifying the
timeline, so a click re-renders only that tool call.

* test(app): cover lone tool calls turning into groups

Desktop journeys check that an opened lone call keeps its details open inside
the new group, that clicking it renders no timeline component, and that the
group header lands where the call was. Compact covers the call sheet and a
closed group.

(cherry picked from commit eee70e7fef)
2026-10-28 17:42:03 +01:00
Mohamed Boudra 20b2dad610 Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer

The sheet tab body padded the bottom safe-area inset, and the composer dock
padded it again, so the parent callout in a subagent sheet floated a full
navigation-bar height above where the composer sits in a tab.

The sheet body (SheetBottomInset) now owns the inset: it pads it once and
tells its content none is left, so the dock stays host-agnostic. Overlay
sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on
the far side of their portal, so ones opened from sheet content still clear
the screen edge.

The callout takes the composer's rail and shows the parent's provider icon
through StatusIcon: no icon box, with the running ring, attention dot or
needs-input alert on the icon.

* fix(app): give the attachment lightbox its own safe area

An RN Modal passes its call site's context straight through, so a lightbox
opened from a sheet tab read the bottom inset the sheet body had already
spent (0) and drew over the navigation bar. The modal is its own window:
it now gets its own SafeAreaProvider, which measures that window, and the
image area reads its insets below it.

floating-panels.md records the rule for overlays: read insets where the
overlay draws.

* feat(app): open a subagent in place of the subagents list on compact

On phones, choosing a subagent from the subagents list now replaces the list
inside the same sheet instead of stacking a second sheet over it. Back returns
to the list, Close closes both, Android Back steps subagent -> list -> closed,
and Open parent lands on the pane the list was opened from (for a nested
subagent, its parent's sheet).

- Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops
  a page via `goBack`, and a page whose definition disappears falls back to
  the list.
- One sheet pane (`screens/workspace/sheet-pane`) renders the header, body,
  bottom inset and focus for both containers: the workspace's detached sheet
  tabs and the in-list menu page. Only the container differs.
- The header drags the sheet through `SheetDragArea`; compact menu sheets use
  the shared `SheetBackground`/`SheetHandle` chrome.
- `selectSheetTab` describes the sheet a reveal would show without opening it.

* fix(app): put the parent callout on one row

The Open parent button already says what the callout is, so the Parent label
goes. The parent's title shrinks to one line before the button does, so Open
parent never wraps or leaves the row.

* refactor(app): register panels once in the workspace screen

Every reader of the panel registry called ensurePanelsRegistered, which
imports every panel module. The panels render those readers (the agent panel's
subagent track and parent callout, the new-tab panel's launcher, the diff
panel's review surface), so each reader closed an import cycle through
register-panels.

The workspace screen, the only route that renders a registry reader, now
registers the panels before anything under it renders, and the readers import
only the leaf panel-registry. The pane content test registers a probe panel for
real instead of mocking the registry.

* test(app): transform the markdown WASM factory when Vite versions its URL

Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made
Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did
not match, so the test file failed to import.

(cherry picked from commit d6d5a266ad)
2026-10-28 17:42:03 +01:00
Mohamed Boudra 58ca9a9b30 fix(app): remove blank space above Android turn footers (#8088)
Android ships generated Markdown props without code.fontScale, so native measurement wraps inline code at a larger size than drawing. Regenerate both Android code structs and enforce generated-header parity for both platforms.

(cherry picked from commit 7a0c6fbd82)
2026-10-28 17:42:03 +01:00
Mohamed Boudra dfa9977047 fix(app): truncate agent sender attribution to one line (#8092)
Constrain the sender label to the message width so long agent names do not wrap above received messages. Keep the line limit opt-in on Button and cover compact chat widths with a real-browser regression.

(cherry picked from commit bede2b90ed)
2026-10-28 17:42:03 +01:00
Mohamed Boudra 4f46e094a1 fix(app): restore expansion of long native user messages (#8089)
Native hidden overflow capped the measured content at the preview height, so long messages never offered Show more. Use the native scroll layout mode on the existing View to measure full content while preserving clipping, and store only whether it overflows.

(cherry picked from commit befc3d5663)
2026-10-28 17:42:03 +01:00
paseo-bot[bot] dd914c1479 Keep docked navigation sizes consistent in narrow windows (#8052)
* fix(app): unify layout and density breakpoints

Align md styles with the existing 600pt compact/wide boundary so
600-719pt tablet windows no longer mix wide layout with compact metrics.
Keep the 500pt composer threshold local to its measured content width.

Let Settings navigation shrink before its detail pane falls below the
existing 400pt target, while retaining the preferred 720pt split budget.

Validated workspace typecheck, lint, formatting, real Chromium resize
checks, Duo portrait and landscape, and existing sidebar geometry tests.

* test(app): cover consistent docked navigation and readable Settings

Compare narrow and wide navigation density and Settings detail space
through the real browser journey, including compact Settings after resize.
The regression fails with main's split layout and density boundaries.

Co-authored-by: Elsa <zeyugao@outlook.com>

---------

Co-authored-by: Elsa <zeyugao@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit ce412d04c3)
2026-10-28 17:42:03 +01:00
paseo-bot[bot] 025e7ef1d8 Keep model-discovery errors away from picker edges (#8039)
* fix(app): pad the model picker empty state on all sides

The empty state carried vertical padding only, so the provider refresh
error it renders wrapped from edge to edge and read as attached to the
model picker's left border on web and mobile.

One padding value keeps the message off the border on every platform.

* test(app): keep model discovery errors inside picker edges

Exercise a real catalog deadline through the existing Pi RPC fixture at desktop and compact browser widths. Both gutter assertions fail without the shared padding fix.

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>

---------

Co-authored-by: Matteo Pietro Dazzi <matteopietro.dazzi@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit de50243885)
2026-10-28 17:42:03 +01:00
Mohamed Boudra b4e837a9fe fix(app): keep grouped reasoning closed when Expand thinking is off (#8042)
Thoughts are grouped with tool calls only while Expand thinking is off,
yet opening a group rendered each thought with `defaultExpanded`, so the
reasoning text popped open against the setting. Grouped thoughts now open
like any other call: a closed Thinking row the reader clicks.

The folded history also treated the latest retained turn as live whenever
the agent was active without a known turn id, which unfolded the previous
turn until the new turn's rows arrived. Without head rows, the latest turn
stays live only while the active run is that turn.

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 25721c7847)
2026-10-28 17:42:03 +01:00
Mohamed Boudra f098f77f4a chore(ci): remove Android module tests from ordinary CI (#8102)
Native unit tests require an Android prebuild and Maven downloads that block otherwise completed app checks. Keep the explicit local test command while removing its CI invocation and dedicated Java setup.

(cherry picked from commit 36fc4f0b43)
2026-10-28 17:42:02 +01:00
Mohamed Boudra f59ebd4fd5 fix(ci): pin Playwright jobs to supported Ubuntu image
The ubuntu-latest rollout moved to Ubuntu 26.04, which the locked browser installer rejects. Keep browser-installing jobs on the same Ubuntu 24.04 image used by the last passing main run.

(cherry picked from commit 1981d61aa2)
2026-10-28 17:42:02 +01:00
Mohamed Boudra aae97b2bdc fix(app): regenerate iOS Markdown inline code props
(cherry picked from commit b3ca2327a8)
2026-10-27 16:45:05 +01:00
532 changed files with 9755 additions and 35753 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into any base get the same checks.
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
merge_group:
workflow_dispatch:
@@ -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
-25
View File
@@ -203,31 +203,6 @@
"readFileSync",
"rmSync"
],
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
"terminal/terminal-worker-process.ts": [
"chmodSync",
"closeSync",
"existsSync",
"openSync",
"rmSync",
"writeSync"
],
// Worker startup only: choose and secure the local listening socket before accepting a controller.
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
"terminal/terminal-worker-runtime.ts": [
"chmodSync",
"existsSync",
"mkdirSync",
"readdirSync",
"readFileSync",
"renameSync",
"rmSync",
"statSync",
"writeFileSync"
],
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
"terminal/paseo-cli-path.ts": ["existsSync"],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
-11
View File
@@ -1,16 +1,5 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
-11
View File
@@ -1,16 +1,5 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进:
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
+4 -5
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 |
@@ -67,7 +66,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
@@ -118,9 +116,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release base and source](docs/release.md#release-base-and-source).
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
+2 -24
View File
@@ -12,26 +12,6 @@ initializing → idle → running → idle (or error → closed)
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
Startup chooses candidates before anything else reads or repairs persisted running state, then
opens connections and resumes every candidate in the background. A provider that is slow to reopen
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
closing runtimes.
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
loop or exactly-once turn guarantee.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
recovery is observed only after its resume was sent, so the parent never hears that it was closed
in between. Archived or deleted parents suppress delivery;
parent-owned creation watches also stop
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
-13
View File
@@ -66,9 +66,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports.
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
@@ -493,16 +490,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"
-2
View File
@@ -34,11 +34,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
- **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`).
+7 -9
View File
@@ -200,15 +200,13 @@ its own.
## Gotchas
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
after the start. Whatever moved or resized the surface in between was undone: content that
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
leaves no inline style; native keeps the Reanimated `Keyframe`.
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
so the entering animation runs at the final position rather than ending unseen at the
off-screen measuring position.
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
-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:
+20 -36
View File
@@ -99,10 +99,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
@@ -337,10 +333,11 @@ export default function contribute(client: PluginClientContext) {
}
```
The contribution function must return cleanup for resources created by plugin code. Server cleanup
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
not interrupt host teardown. Client installation retention and disposal follow the public
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
@@ -385,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -440,22 +428,21 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
## Lifecycle hooks
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
@@ -550,7 +537,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
@@ -626,10 +613,7 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. An installation
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
also run after reconnect; replace your view before applying its subsequent updates. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
-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
+73 -135
View File
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (reversible — agent does this):
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
- resolve the release source to one commit and confirm that commit's existing CI is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
@@ -17,10 +17,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps:
@@ -28,128 +27,56 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Main stays open
## Release source and CI
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
## Choosing a path
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
The user decides when the changes on `main` warrant a release and how much risk it takes:
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
review often takes about a day, with no guaranteed timing.
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
## Release branch discipline
A beta follows these defaults, which the user can explicitly override:
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
them.
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
pace.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
npm dist-tags are independent pointers:
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
newest stable.
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
newer than the version `beta` points to, which covers promotions and direct stable releases, so
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
`beta` on the active newer beta.
### Hotfix from a release tag
## Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| Release | Base | Changes on top |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
An explicit user instruction can select another base.
1. Fetch `origin` with tags and resolve the base to one commit.
2. Create the release workspace through Paseo MCP on a new branch from the base, named
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
this workspace: `release:push` pushes `HEAD` to the current branch.
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
Resolve conflicts within the approved changes and report how. A resolution that needs new
behavior is a scope addition: surface it for selection.
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
because CI push runs target only `main`.
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
6. [Reconcile `main`](#reconcile-main).
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
## Release inventory
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
not, then get the batch approved. There is no release label or queue.
1. Find the commits on `origin/main` whose patches are not in the base tag:
```bash
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
```
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
content over PR state or branch names.
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
prerequisites an included PR depends on and anything that adds scope beyond what the beta
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
preserves its behavior, directly or through a superseding change. List the missing ones for
explicit selection; adding one to a promotion needs a new beta.
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
authorization they already gave covers it.
For a promotion, every item defaults to defer.
## Reconcile main
After the release push, open a PR into `main` that brings in what shipped and keeps newer
development on `main`:
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
below any newer entries.
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
carries a newer version, plus the F-Droid changelog files a stable release generates.
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
`main` holds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides `main`.
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
@@ -164,6 +91,21 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full diff from the previous
@@ -191,7 +133,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -203,9 +145,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
do not republish the packages:
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -214,8 +156,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, before considering the stable release complete.
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
@@ -233,7 +175,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -380,8 +322,7 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
@@ -547,7 +488,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the code you want built, with the fix included.
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
@@ -690,7 +631,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -722,11 +663,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -737,24 +678,22 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -768,5 +707,4 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
+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.
-83
View File
@@ -1,83 +0,0 @@
# Terminal worker
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
## Process model
```
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
exits (re-parented to init)
```
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
- 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
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
## Protocol versions
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
| Worker reports | Daemon does |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| its own version | adopts it |
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
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.
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:
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
- Dev (`.ts` source) launches from source and copies nothing.
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
## Desktop
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
## Known limits
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
+6 -17
View File
@@ -10,27 +10,16 @@ then replaces the previous tool state or merges into the current text item. Inte
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> 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
@@ -69,8 +58,8 @@ Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. An underfilled viewport
loads older history until it fills or history is exhausted. Further history is user-driven.
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
-8
View File
@@ -358,14 +358,6 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
owned by Unistyles' change listeners. Chromium defers their events while a window
is hidden; reading `matches` on those same lists can consume the pending change.
CSS variables then follow the OS theme while `withUnistyles` props retain the old
colors. Keep this patch until upstream reads the scheme through separate lists.
The desktop browser E2E checks both transition directions in an isolated Electron
process, because Playwright's focus emulation prevents real hidden-page behavior.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
+1 -1
View File
@@ -1 +1 @@
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
-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",
@@ -1,27 +0,0 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "default",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
-35
View File
@@ -59,38 +59,3 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -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,23 +1,14 @@
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,
restartDaemonInSettings,
expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
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 ({
@@ -38,57 +29,6 @@ test("settings restart waits for a replacement worker while retaining the superv
}
});
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
@@ -124,51 +64,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 });
@@ -1,13 +1,3 @@
import {
withRetainedPlugin,
editRetainedDraft,
expectRetainedDraft,
expectStoredPluginTitle,
hideRetainedPreferences,
showRetainedPreferences,
expectChangedPlugin,
expectRemovedPlugin,
} from "../support/helpers/plugin-reconnect";
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -198,51 +188,3 @@ test("plugin content and host labels follow app language without losing an open
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
page,
}) => {
test.setTimeout(180_000);
await withRetainedPlugin(page, async (probe) => {
await probe.open();
await editRetainedDraft(page, "Connected draft", 0);
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
await probe.disconnect();
await expectRetainedDraft(page, "Connected draft", 1);
await editRetainedDraft(page, "Offline draft", 1);
await probe.changeStoredTitle("Saved by another client");
await expectStoredPluginTitle(page, "Agent monitor");
await probe.reconnect();
await expectStoredPluginTitle(page, "Saved by another client");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("inactive settings refresh without replacing the installation", async () => {
await hideRetainedPreferences(page);
await probe.disconnect();
await probe.changeStoredTitle("Saved while preferences hidden");
await probe.reconnect();
await showRetainedPreferences(page);
await expectStoredPluginTitle(page, "Saved while preferences hidden");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("changed code replaces the retained installation after reconnect", async () => {
await probe.disconnect();
await probe.changeCode();
await expectRetainedDraft(page, "Offline draft", 2);
await probe.reconnect();
await expectChangedPlugin(page);
await editRetainedDraft(page, "New code draft", 0);
});
await test.step("removal is reconciled after reconnect", async () => {
await probe.disconnect();
await probe.remove();
await expectRetainedDraft(page, "New code draft", 1);
await probe.reconnect();
await expectRemovedPlugin(page);
});
});
});
@@ -1,5 +1,4 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -7,8 +6,6 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -204,26 +204,19 @@ test.describe("Projects settings", () => {
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectViewKey: string | null = null;
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectViewKey);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectViewKey) {
const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -30,10 +30,6 @@ import {
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease,
} from "../support/helpers/settings";
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId);
});
});
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
@@ -73,7 +66,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
@@ -1,134 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
@@ -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);
});
}
});
+7 -59
View File
@@ -407,86 +407,34 @@ async function seedWholeTurnHistory() {
return agent;
}
async function expectInitialFillIsConcealed(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
}
async function expectFilledChatAtBottom(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
const chat = page.getByTestId("agent-chat-scroll");
await expect(chat).toHaveCSS("opacity", "1");
await expect
.poll(() =>
chat.evaluate((element) => ({
hasOverflow: element.scrollHeight > element.clientHeight,
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
})),
)
.toEqual({ hasOverflow: true, atBottom: true });
}
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
await expectInitialFillIsConcealed(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectInitialFillIsConcealed(page);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await page.setViewportSize({ width: 1280, height: 8000 });
await history.expectRequestedPages(3);
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
history.releasePage(3);
await history.expectSettledWithRequestedPages(3);
history.expectNoRepeatedEntries();
} finally {
await agent.cleanup();
}
});
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectRequestedPages(2);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(3);
history.releasePage(3);
await page.getByTestId("scroll-to-bottom-button").click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets in (28|29)m\s·\s/,
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
@@ -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");
}
@@ -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;
@@ -1,7 +1,3 @@
import { execFile, spawn } from "node:child_process";
import { writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -19,27 +15,14 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
await openHostSection(page, daemon.serverId, "host");
}
export async function restartDaemonInSettings(
page: Page,
options: { closeTerminals?: boolean } = {},
) {
const button = page.getByRole("button", {
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
exact: true,
});
export async function restartDaemonInSettings(page: Page) {
const button = page.getByRole("button", { name: "Restart", exact: true });
await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
}
export async function expectDaemonRestartFailed(page: Page, text: string) {
const error = page.getByTestId("host-page-restart-error");
await expect(error).toBeVisible({ timeout: 30_000 });
await expect(error).toContainText(text);
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
}
export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000,
@@ -57,139 +40,3 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
await client.close();
}
}
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminal",
});
try {
const workspace = await client.createWorkspace({
source: { kind: "directory", path: daemon.paseoHome },
title: "lifecycle",
});
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
workspaceId: workspace.workspace.id,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1e9)"],
});
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
return created.terminal.id;
} finally {
await client.close();
}
}
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminals",
});
try {
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
} finally {
await client.close();
}
}
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
export interface UnclosableTerminalProgram {
pid: number;
/** Ends the program's parent, which lets the system reap the program. */
release(): void;
}
/**
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
* it. A kill cleanup can only report it as still running. POSIX only.
*/
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
const record = {
recordId: "planted-record",
pid: gone.pid,
bootId: null,
createdAt: new Date().toISOString(),
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
protocolVersion: 1,
paseoVersion: "0.0.0-test",
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
};
await writeFile(
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
return { pid, release };
} catch (error) {
release();
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;
@@ -161,24 +152,6 @@ export async function startIsolatedHostDaemon(
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,
@@ -196,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 },
@@ -218,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 });
}
@@ -236,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,159 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { settingsRpc } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { z } from "zod";
import { expect, type Page } from "../fixtures";
import { gotoAppShell } from "./app";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { connectDaemonClient } from "./daemon-client-loader";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
const PLUGIN_ID = "retained-plugin-e2e";
export function retainedPluginSource(version: string) {
return `import { useState } from "react";
import { Text, View, TextInput, Pressable } from "react-native";
import { useSettings } from "@getpaseo/plugin/client";
import { preferences } from "./shared/preferences";
function Preferences({ color }) {
const settings = useSettings(preferences);
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
}
function Surface({ theme }) {
const [draft, setDraft] = useState("");
const [clicks, setClicks] = useState(0);
const [showPreferences, setShowPreferences] = useState(true);
const color = theme.colors.foreground;
return <View>
<Text style={{ color }}>Plugin version ${version}</Text>
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
{showPreferences ? <Preferences color={color} /> : null}
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
return () => {};
}`;
}
export async function withRetainedPlugin(
page: Page,
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
) {
const probe = await createProbe(page);
try {
await run(probe);
} finally {
await probe.close();
}
}
async function createProbe(page: Page) {
const example = await copyPluginExample("settings");
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
const previous = await peer.getDaemonConfig();
const gate = await installDaemonWebSocketGate(page);
const clientEntry = path.join(example.directory, "index.client.tsx");
let catalogCount = 0;
try {
await writeFile(
path.join(example.directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(clientEntry, retainedPluginSource("one"));
await peer.patchDaemonConfig({ pluginsEnabled: true });
await peer.installDirectoryPlugin(example.directory);
} catch (error) {
await peer.close();
await example.cleanup();
throw error;
}
return {
async open() {
await page.setViewportSize({ width: 1100, height: 800 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
},
async disconnect() {
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
await gate.drop();
await gate.waitForBlockedConnection();
},
async reconnect() {
gate.restore();
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
},
async changeStoredTitle(title: string) {
const rpc = settingsRpc("display");
const current = rpc.read.output.parse(
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
);
if (current.status !== "ready") throw new Error("Expected ready settings");
const values = z
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
.parse(current.values);
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
revision: current.revision,
values: { ...values, title },
});
expect(result).toMatchObject({ status: "saved", values: { title } });
},
async changeCode() {
await writeFile(clientEntry, retainedPluginSource("two"));
await peer.reloadPlugin(PLUGIN_ID);
},
async remove() {
await peer.removePlugin(PLUGIN_ID);
},
async close() {
gate.restore();
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await peer.close();
await example.cleanup();
},
};
}
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
await expectRetainedDraft(page, draft, clicks + 1);
}
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
await expect(
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
).toBeVisible();
}
export async function expectStoredPluginTitle(page: Page, title: string) {
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
}
export async function hideRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
}
export async function showRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
}
export async function expectChangedPlugin(page: Page) {
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
await expectRetainedDraft(page, "", 0);
}
export async function expectRemovedPlugin(page: Page) {
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
}
@@ -5,64 +5,8 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -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<{
@@ -560,41 +560,3 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
function restartRecoverySwitch(page: Page) {
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
}
function restartRecoveryEditButton(page: Page) {
return page.getByRole("button", { name: "Edit prompt", exact: true });
}
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
const toggle = restartRecoverySwitch(page);
await toggle.click();
await expectRestartRecoveryEnabled(page, enabled);
}
// The prompt is only editable while restart recovery is on.
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
}
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
await sheet.getByRole("button", { name: "Save", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
prompt,
);
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
@@ -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 -17
View File
@@ -26,22 +26,6 @@ def stage_app_store_screenshots
root
end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -148,7 +132,7 @@ platform :android do
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: stage_google_play_screenshots,
metadata_path: ANDROID_METADATA,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
@@ -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",
@@ -1,238 +1,197 @@
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
createHistoryStartPagination,
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const initial: HistoryStartPaginationInput = {
function createArmedHistoryStartPaginationState() {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "20",
progressKey: "epoch-1:20",
};
function harness(initiallyVisible = false) {
let geometry = { ...initial };
const requests: Array<(value: boolean) => void> = [];
let settles = 0;
const pagination = createHistoryStartPagination({
initiallyVisible,
read: () => geometry,
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
changed: () => {},
scheduleSettle: () => {
settles++;
},
});
return {
pagination,
requests,
get settles() {
return settles;
},
set(patch: Partial<HistoryStartPaginationInput>) {
geometry = { ...geometry, ...patch };
pagination.update();
},
};
}
describe("history start pagination", () => {
it("waits for a delayed request and its committed page before settling", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
expect(h.settles).toBe(0);
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(1);
expect(h.pagination.state.visible).toBe(false);
h.set({ isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("waits for the committed page after a request completes", async () => {
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(0);
h.set({ progressKey: "10" });
expect(h.settles).toBe(1);
h.pagination.dispose();
});
it("continues short pages and reveals on exhaustion", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(2);
h.set({ hasOlderHistory: false });
h.requests[1](true);
await Promise.resolve();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("releases a failed or stalled request and allows retry", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.rearm();
expect(h.requests).toHaveLength(2);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("keeps visible history visible while filling", () => {
const h = harness(true);
h.pagination.update();
expect(h.pagination.state.visible).toBe(true);
h.pagination.dispose();
});
it("does not load a full viewport until user intent and respects inactive views", () => {
const h = harness();
h.set({ isUnderfilled: false });
expect(h.requests).toHaveLength(0);
h.set({ isReady: false });
h.pagination.rearm();
expect(h.requests).toHaveLength(0);
h.set({ isReady: true });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("preserves the reading position after an anchored page moves away from the start", async () => {
const h = harness(true);
h.set({ isUnderfilled: false });
h.pagination.rearm();
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(1);
h.set({ distanceFromHistoryStart: 0 });
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
});
it("ignores a timed-out response after the reader retries", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
h.pagination.rearm();
h.requests[0](true);
await Promise.resolve();
expect(h.pagination.state.status).toBe("loading");
expect(h.settles).toBe(0);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("does not repeat a successful page that made no progress", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
h.pagination.update();
expect(h.requests).toHaveLength(1);
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.dispose();
vi.useRealTimers();
});
it("waits until the cursor and active history are available", () => {
const h = harness();
h.set({ isReady: false, progressKey: null });
h.set({ isReady: true, isLoadingOlderHistory: true });
h.set({ isLoadingOlderHistory: false });
expect(h.requests).toHaveLength(0);
h.set({ progressKey: "20" });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("reveals immediately on exhausted history without offering retry", () => {
const h = harness();
h.set({ hasOlderHistory: false });
expect(h.requests).toHaveLength(0);
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps a settled page pending while its view is inactive", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.set({ isReady: false });
h.pagination.settle();
expect(h.pagination.state.status).toBe("settling");
expect(h.requests).toHaveLength(1);
h.set({ isReady: true, isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
const h = harness();
h.pagination.reveal();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.pagination.state.visible).toBe(true);
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
it.each([
[true, 123, 0],
[false, 123, 0],
[true, 0, 123],
[false, 0, 123],
])(
"handles native padding when short history has older=%s",
(hasOlderHistory, topOverlayInset, bottomPadding) => {
const geometry = nativeHistoryStartGeometry({
contentHeight: 825,
viewportHeight: 700,
offsetY: 0,
topOverlayInset,
bottomPadding,
it("treats locally revealed history as progress before remote pagination", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const localReveal = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:20:local-60",
});
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
pagination.update();
expect(requests).toBe(hasOlderHistory ? 1 : 0);
expect(pagination.state.visible).toBe(!hasOlderHistory);
pagination.dispose();
},
);
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
});
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
true,
false,
false,
]);
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
});
it("loads one page each time anchored geometry leaves and returns to history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const settledAway = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(settledAway.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([
first.shouldLoad,
pageApplied.shouldLoad,
settledAway.shouldLoad,
returned.shouldLoad,
]).toEqual([true, false, false, true]);
});
it("continues the same loading operation when anchored geometry remains at history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const continued = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect(continued.shouldLoad).toBe(true);
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
});
it("latches a request that finishes without cursor progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
const away = evaluateHistoryStartPagination(duplicate.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
});
expect(finished.state).toEqual({ status: "latched" });
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
false,
false,
false,
]);
expect(away.state).toEqual({ status: "ready" });
});
it("allows another user attempt after a request finishes without progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(failed.state),
visibleHistoryStart,
);
expect(failed.state).toEqual({ status: "latched" });
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
});
it("waits while history loading is unavailable or already active", () => {
const state = createArmedHistoryStartPaginationState();
expect([
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
.shouldLoad,
]).toEqual([false, false, false, false]);
});
});
@@ -1,135 +1,138 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export type HistoryStartPaginationState =
| { status: "dormant" }
| { status: "ready" }
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
| { status: "settling"; loadedProgressKey: string }
| { status: "latched" };
export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number;
isUnderfilled: boolean;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
}
export interface HistoryStartPaginationState {
status: "dormant" | "ready" | "loading" | "settling" | "failed";
visible: boolean;
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
/** Owns one pagination operation, including the initial viewport reveal. */
export function createHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
changed: (state: HistoryStartPaginationState) => void;
scheduleSettle: () => void;
}) {
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
let requestedKey: string | null = null;
let completed = false;
let fill = false;
let generation = 0;
let timeout: ReturnType<typeof setTimeout> | undefined;
export function rearmHistoryStartPagination(
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
if (status === state.status && visible === state.visible) return;
state = { status, visible };
input.changed(state);
};
const clear = () => {
generation++;
clearTimeout(timeout);
timeout = undefined;
};
const finish = (status: HistoryStartPaginationState["status"]) => {
clear();
publish(status, true);
};
const fail = () => finish("failed");
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
const update = () => {
const geometry = input.read();
if (!geometry) return;
if (state.status === "loading") {
if (!completed) return;
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
publish("settling");
input.scheduleSettle();
} else if (!geometry.hasOlderHistory) {
finish("dormant");
}
return;
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status === "dormant") {
return { state, shouldLoad: false };
}
if (state.status === "loading") {
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
return {
state: { status: "settling", loadedProgressKey: input.progressKey },
shouldLoad: false,
};
}
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (state.status === "dormant" && !geometry.isUnderfilled) {
publish("dormant", true);
return;
if (input.isLoadingOlderHistory && !state.requestObserved) {
return { state: { ...state, requestObserved: true }, shouldLoad: false };
}
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
if (!geometry.hasOlderHistory) {
finish("dormant");
return;
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
return { state: { status: "latched" }, shouldLoad: false };
}
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
fill = geometry.isUnderfilled;
requestedKey = geometry.progressKey;
completed = false;
publish("loading");
const request = ++generation;
timeout = setTimeout(() => {
if (request === generation) fail();
}, 15_000);
void input.load().then(
(applied) => {
if (request !== generation) return undefined;
if (!applied) {
fail();
return undefined;
}
completed = true;
update();
return undefined;
},
() => {
if (request === generation) fail();
},
);
};
if (
state.requestObserved &&
!input.isLoadingOlderHistory &&
input.progressKey === state.requestedProgressKey
) {
return { state: { status: "latched" }, shouldLoad: false };
}
return { state, shouldLoad: false };
}
if (state.status === "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (!isAtHistoryStart) {
return state.status === "ready"
? { state, shouldLoad: false }
: { state: { status: "ready" }, shouldLoad: false };
}
if (
state.status === "latched" ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return { state, shouldLoad: false };
}
return {
get state() {
return state;
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
update,
settle() {
if (state.status !== "settling") return;
const geometry = input.read();
if (!geometry?.isReady) return;
clear();
if (fill && !geometry.isUnderfilled) {
publish("dormant", true);
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
publish("ready", true);
} else if (!geometry.hasOlderHistory) {
publish("dormant", true);
} else {
publish("ready");
update();
}
shouldLoad: true,
};
}
export function settleHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status !== "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (
!isAtHistoryStart ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return {
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
shouldLoad: false,
};
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
rearm() {
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
update();
},
reveal() {
publish(state.status, true);
},
reset(visible: boolean) {
clear();
publish("dormant", visible);
},
dispose: clear,
shouldLoad: true,
};
}
@@ -1,13 +0,0 @@
export function nativeHistoryStartGeometry(input: {
contentHeight: number;
viewportHeight: number;
offsetY: number;
topOverlayInset: number;
bottomPadding: number;
}) {
const contentHeight = input.contentHeight - 32 - input.topOverlayInset - input.bottomPadding;
return {
distanceFromHistoryStart: contentHeight - input.viewportHeight - input.offsetY,
isUnderfilled: contentHeight < input.viewportHeight,
};
}
+110 -120
View File
@@ -1,4 +1,12 @@
import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef } from "react";
import {
Fragment,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Platform,
@@ -11,9 +19,6 @@ import {
} from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useTranslation } from "react-i18next";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -29,11 +34,15 @@ import {
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
@@ -56,16 +65,6 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
const viewportStyle: ViewStyle = { flex: 1 };
const initialFillStyle: ViewStyle = {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
};
const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string {
@@ -84,8 +83,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -96,8 +93,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle,
strategy,
} = props;
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
@@ -118,6 +113,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
@@ -133,40 +132,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
bottomPadding: streamKeyboardInset.contentContainerPaddingBottom,
}),
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const resetHistoryStart = useStableEvent(() =>
historyStartPagination.reset(!isInitialHistoryLoading),
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
);
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -174,9 +181,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -315,7 +328,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = false;
historyStartReadyRef.current = false;
resetHistoryStart();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -323,18 +338,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [
agentId,
clearNativeViewportSettling,
clearPendingUserScrollEnd,
disposeHistoryStart,
evaluateHistoryStart,
resetHistoryStart,
]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
@@ -414,18 +421,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
finishInitialFill();
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
historyStartPagination.rearm();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -499,25 +506,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
useEffect(() => {
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
isHistoryStartSettling,
]);
const renderItem = useStableEvent(
@@ -562,64 +566,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation && (
{isLoadingOperation ? (
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
) : null}
</View>
);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
}, [historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
return (
<View style={viewportStyle}>
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
{!historyStartPaginationState.visible ? (
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+80 -74
View File
@@ -20,7 +20,6 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useTranslation } from "react-i18next";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
@@ -31,10 +30,15 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -73,14 +77,6 @@ const historyStartSlotStyle: CSSProperties = {
};
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
const initialFillStyle: CSSProperties = {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
};
const streamRowStyle: CSSProperties = {
display: "flex",
@@ -302,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onReadingPositionChange,
onNearHistoryStart,
@@ -324,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
[historyMounted, historyVirtualized, inputSegments],
);
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const rowsHostRef = useRef<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
@@ -360,6 +351,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
@@ -484,39 +479,60 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
return {
distanceFromHistoryStart: scrollContainer.scrollTop,
isUnderfilled:
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
scrollContainer.clientHeight,
hasOlderHistory,
isLoadingOlderHistory,
// An explicit jump owns the landing position. Revealing another local
// page here changes the virtual index before the target has mounted.
isReady:
historyStartReadyRef.current &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const resetHistoryStart = useStableEvent(() =>
historyStartPagination.reset(!isInitialHistoryLoading),
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
);
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed === historyStartPaginationStateRef.current) {
return;
}
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -524,12 +540,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory;
},
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -756,7 +782,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
@@ -764,10 +789,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
rearmHistoryStartFromUserIntent();
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
@@ -806,7 +827,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
resetHistoryStart();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
@@ -815,11 +838,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [disposeHistoryStart, evaluateHistoryStart, props.agentId, readingAnchor, resetHistoryStart]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -908,18 +930,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
}
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -958,7 +978,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry;
}
reconcileReadingPosition();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
@@ -992,7 +1012,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
@@ -1210,9 +1229,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Detached reading has one owner: reconcileReadingPosition.
overflowAnchor: followOutput ? "auto" : "none",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
opacity: historyStartPaginationState.visible ? 1 : 0,
};
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
@@ -1289,21 +1307,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation && (
{isLoadingOperation ? (
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
) : null}
</div>
);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
}, [historyStartPaginationState]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1323,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1351,15 +1362,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
+1 -3
View File
@@ -87,13 +87,11 @@ export interface StreamRenderInput {
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
onNearBottomChange: (value: boolean) => void;
// The history row under the top of the viewport, for surfaces that mark where the reader
// is in the transcript. Only the web viewport measures it today.
onReadingPositionChange?: (rowId: string | null) => void;
onNearHistoryStart: () => Promise<boolean>;
onNearHistoryStart: () => boolean | Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -1,33 +0,0 @@
import { useRef, useState } from "react";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
createHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationState,
} from "./history-start-pagination";
export function useHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
scheduleSettle: () => void;
}) {
const read = useStableEvent(input.read);
const load = useStableEvent(input.load);
const scheduleSettle = useStableEvent(input.scheduleSettle);
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
status: "dormant",
visible: input.initiallyVisible,
}));
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
if (!controller.current) {
controller.current = createHistoryStartPagination({
initiallyVisible: input.initiallyVisible,
read,
load,
scheduleSettle,
changed: setState,
});
}
return { state, controller: controller.current };
}
-12
View File
@@ -281,8 +281,6 @@ export interface AgentStreamViewProps {
turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
@@ -339,8 +337,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -1222,8 +1218,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1375,12 +1369,6 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
reasons.push("isInitialHistoryLoading");
}
if (left.revealInitialHistory !== right.revealInitialHistory) {
reasons.push("revealInitialHistory");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -23,7 +22,14 @@ import {
type ComponentType,
type RefObject,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Modal, Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
+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,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
+14 -21
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchStateRef = useRef<PointerState | null>(null);
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,30 +151,23 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchStateRef.current?.drag.finish())
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchStateRef.current = null;
touchDragRef.current = null;
setDragging(false);
});
@@ -48,7 +48,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -184,8 +183,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
const mainRoot = useMemo(
@@ -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,

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