Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4a8ed8f588 fix(server): ACP option applies run one at a time
Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.
2026-11-01 21:29:07 +01:00
Mohamed Boudra 4d76f2176b fix(server): draft speed shows the tier the first turn will run with
Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.
2026-11-01 21:20:09 +01:00
Mohamed Boudra e4a49b414f fix(server): profile options convert against the provider graph a write commits
A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.
2026-11-01 21:09:10 +01:00
Mohamed Boudra fbd7890f78 fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends
Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.
2026-11-01 21:03:31 +01:00
Mohamed Boudra 83ae20ff06 fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion
convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.
2026-11-01 20:53:09 +01:00
Mohamed Boudra 9df005f602 fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none
OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.
2026-11-01 20:18:18 +01:00
Mohamed Boudra d1a1eba23d fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted
The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.
2026-11-01 20:07:00 +01:00
Mohamed Boudra 133481d75c fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed
Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.
2026-11-01 19:59:10 +01:00
Mohamed Boudra e640fe4281 fix(server): a rejected OpenCode auto_accept leaves the session asking for permission
applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.
2026-11-01 19:49:12 +01:00
Mohamed Boudra 43f7dc4ad1 refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id
The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
2026-11-01 19:47:11 +01:00
Mohamed Boudra f783c46301 fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied
A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.
2026-11-01 19:41:34 +01:00
Mohamed Boudra 2fddf0b98c refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters
Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.
2026-11-01 19:33:45 +01:00
Mohamed Boudra 593773a381 fix(server): an OMP replacement runtime starts with the Fast the session has now
A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.
2026-11-01 18:57:05 +01:00
Mohamed Boudra e4d4ba7489 fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults
A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.
2026-11-01 18:49:11 +01:00
Mohamed Boudra 1b5731cebe fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id
A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.
2026-11-01 18:41:01 +01:00
Mohamed Boudra 0f1ebda942 fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default
The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.
2026-11-01 18:32:36 +01:00
Mohamed Boudra 7a1eae245b fix(server): every opening path hands registration the options it launched with
Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.
2026-11-01 17:51:37 +01:00
Mohamed Boudra 3383ee80bc fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values
An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.
2026-11-01 17:44:20 +01:00
Mohamed Boudra 5cdbfef48b fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed
Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.
2026-11-01 17:30:53 +01:00
Mohamed Boudra 0c378889d1 fix(server): an option a session drops when opening is not brought back by a configured default
The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.
2026-11-01 17:20:51 +01:00
Mohamed Boudra 8460e5126a fix(server): a live apply shows the merged defaults the provider was given in the options snapshot
The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.
2026-11-01 17:11:02 +01:00
Mohamed Boudra 51a87b98b9 fix(server): a loaded agent's options stay what its session opened with when a provider default is edited
effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.
2026-11-01 17:05:09 +01:00
Mohamed Boudra 427c2d0743 fix(server): ACP reports a select that appears after startup with its native value
A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.
2026-11-01 17:01:54 +01:00
Mohamed Boudra a8d539ca7c refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with
A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.
2026-11-01 16:42:17 +01:00
Mohamed Boudra 993eef1668 fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs
A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.
2026-11-01 16:35:18 +01:00
Mohamed Boudra c2677a2690 fix(server): a live options patch is rejected when the session cannot apply it
A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.
2026-11-01 16:03:05 +01:00
Mohamed Boudra f3c3461d67 fix(server): Claude rejects a live change to an option it reads when the query starts
Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.
2026-11-01 15:52:08 +01:00
Mohamed Boudra 85edc9da9c fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with
A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.
2026-11-01 15:44:24 +01:00
Mohamed Boudra fd2315495f refactor(server): the legacy featureValues folds take one named-input object 2026-11-01 15:34:06 +01:00
Mohamed Boudra 3a3300b69e fix(server): a plugin that moves settings and then refuses an apply reports what moved 2026-11-01 15:34:06 +01:00
Mohamed Boudra 0fef7414fc fix(server): Claude applies only the keys a patch sets over its current options
A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.
2026-11-01 15:34:06 +01:00
Mohamed Boudra c0c181425f fix(server): Codex returns the tier it resolved and drops a speed the new model lacks
A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.
2026-11-01 15:34:06 +01:00
Mohamed Boudra 301b49e381 test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch 2026-11-01 15:22:56 +01:00
Mohamed Boudra ee936d51c0 test(server): assert the forwarded plugin setting without nesting callbacks 2026-11-01 15:21:02 +01:00
Mohamed Boudra 8e28e3988b test(server): a setting a plugin changes on its own is forwarded as options_changed 2026-11-01 15:20:05 +01:00
Mohamed Boudra 62df2f3253 test(server): a patch key is resolved against nested configured defaults 2026-11-01 15:18:24 +01:00
Mohamed Boudra aba0c78449 docs(server): a rejected apply reports what the provider moved after the call ends 2026-11-01 15:15:20 +01:00
Mohamed Boudra a0754e9f9b fix(server): fold an old app's profile featureValues by the provider and options the write carries
A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.
2026-11-01 15:15:20 +01:00
Mohamed Boudra 9d8747cd3a fix(server): Claude reports Fast as off, not removed, when a model switch drops it
A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.
2026-11-01 15:15:20 +01:00
Mohamed Boudra 13ad4a13ff fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch
Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.
2026-11-01 15:10:35 +01:00
Mohamed Boudra 9de8cbf2b8 fix(server): ACP reports the selects it moved during open or apply as that call's result
A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.
2026-11-01 15:10:35 +01:00
Mohamed Boudra 2dd83e43cb fix(server): apply an old app's profile featureValues over the profile's stored options
The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.
2026-11-01 15:10:35 +01:00
Mohamed Boudra ac6b5440e8 refactor(server): providers return what apply and open ended with instead of reporting it afterwards
applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.
2026-11-01 14:55:27 +01:00
Mohamed Boudra 0da2781520 refactor(server): providers report option differences once, after create, resume and apply
Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.

The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.

A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.
2026-11-01 14:40:11 +01:00
Mohamed Boudra f487316a9d chore(website): regenerate the config schema for profile options 2026-11-01 08:17:11 +01:00
Mohamed Boudra 1ee149c85b fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record
Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 72d488f17a fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way
A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 8e83d9346a fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 6c22bc72df fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
2026-11-01 07:53:29 +01:00
Mohamed Boudra bc00edf904 feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
2026-11-01 07:53:29 +01:00
Mohamed Boudra afce426a43 feat(protocol): option controls, options patch, and provider.controls.list
Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).
2026-11-01 07:53:28 +01:00
paseo-bot[bot]andDominic Anaeto 14519bdb5b Read archived Pi chats after their working folder is removed (#8442)
* fix(pi): open an archived Pi agent's history after its folder is removed

Opening an archived agent's timeline resumes its provider for history.
Pi's resumeSession never received the resume purpose, so it launched the
Pi RPC process, and its MCP support probe, in the agent's stored working
directory. When that folder had been removed, for example with a merged
worktree, Node failed the spawn with "spawn <command> ENOENT" and the
history could not be read.

Starting the process elsewhere is not enough: with --session, Pi checks
the cwd stored in the session file's header and, outside interactive
mode, exits when that folder is missing. A history resume whose stored
folder is no longer a directory now starts Pi from the home directory on
a temporary copy of the session file whose header names that directory.
The copy streams everything after the header unchanged and is deleted
when the session closes. The agent keeps reporting its stored folder and
original session file, and the original file is not written to.

The directory check moves out of the ACP provider, which already starts
history loads from the home directory (#6228), into a shared helper both
providers use.

Fixes #7969

* test(terminal): keep the argv test child alive through its assertion

The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:52:48 +00:00
Mohamed Boudra 891e62309c fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.
2026-11-01 13:35:11 +07:00
paseo-bot[bot] 6ae6308cfb test(server): control failed Git clone responses in credential test (#8428)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:01:30 +00:00
Mohamed Boudra ef77b823ae chore(release): reconcile 0.15.0-beta.1 metadata (#8443)
* docs(changelog): prepare 0.15.0-beta.1

(cherry picked from commit 7e5ad53491)

* chore(release): cut 0.15.0-beta.1

(cherry picked from commit 8bd38fb236)

* test(docs): follow nested worktrees page
2026-11-01 12:56:41 +07:00
Mohamed Boudra 60f29a4350 docs(worktrees): add nested worktreeinclude reference 2026-11-01 06:31:01 +01:00
paseo-bot[bot]andDominic Anaeto 8efb21f55c fix(server): close abandoned workspace service connections (#8421)
* fix(server): close the upstream connection when a proxied client leaves

The service proxy piped the service's response to the client but never
destroyed the upstream request when the client went away. The upstream
socket stayed open and unread, so a Server-Sent Events service kept
writing into it and grew in memory. The proxy now destroys the upstream
request when the client response closes before it finished, without
logging that deliberate abort as an unreachable upstream, and a
WebSocket upgrade closes the service socket when the client socket
closes.

Fixes #8149

* test(server): publish terminal activity fixture atomically

File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(server): keep rollback listener ports distinct

Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 03:55:48 +00:00
paseo-ai[bot] b2666a0aec fix: update lockfile signatures and Nix hash [skip ci] 2026-11-01 03:43:24 +00:00
Mohamed Boudra 82d5f6c580 fix(app): keep layout sized to the window after rotation (#8377)
A style update racing a window resize could recommit old root constraints. Rebuild the style update from the current root on each commit attempt.
2026-11-01 10:34:06 +07:00
Mohamed Boudra da75aa7a4a Fix terminal emoji size and alignment (#8375)
* fix(terminal): render emoji at their intended width

The native grid scales glyphs to the columns assigned by the terminal buffer. Use grapheme widths across current hosts and clients so emoji fit their slots and replay preserves cursor positions.

* fix(terminal): make width regression test portable

* test(terminal): wait for replay fixture output to finish

A trailing newline could arrive after the readiness marker and change the retained history between replay captures.
2026-11-01 10:33:59 +07:00
86c60d367c fix(claude): keep a resumed subagent on its row after the session reopens (#8408)
SendMessage resumes a finished Claude subagent under its original task_id
but a new tool_use_id, and the child's frames still name the original Task
call as their parent. In the same process the live source already maps the
new tool id onto the original row. After the session reopens, the child is
only known from its transcript, so the resume was declared as a new
subagent keyed by the SendMessage id: a second row, and every child frame
dropped as undeclared. That row never showed the model or the child's
reply.

Replay now hands the live source each restored child's task id, id and
presentation. The first task_started for that task id adopts the restored
row, so the resume takes the same path it takes without a reopen.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 01:05:02 +00:00
paseo-bot[bot] 5afa1a9f8e test(server): isolate cold Git filter from control work (#8397)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 00:01:29 +00:00
paseo-bot[bot]andOkan YILDIRIM 2d7adfc450 fix(app): restore directory subscriptions after demand returns (#8403)
Invalidate refresh satisfaction when subscriptions are released, and retry
if demand returns before the overtaken refresh completes. Otherwise the
same connection source can stay satisfied with no live list listeners.

Cover both demand sources and completion orderings with live delta checks.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-10-31 23:58:14 +00:00
paseo-bot[bot] 7d9741ef77 fix(app): expose the SSL checkbox checked state on web (#8399)
The web renderer does not map accessibilityState.checked onto aria-checked. Supply the checked property explicitly so accessibility clients agree with the checkmark and connection URI.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 22:50:27 +00:00
paseo-bot[bot]andluke caf9300358 fix(server): preserve running status after agent creation (#8396)
* fix(server): forward a created agent's current state, not its pre-prompt copy

create_agent_request forwarded the snapshot createAgent returned when
registration finished, after the initial prompt had already published
the agent as running. Subscribers received that idle copy last, with an
older updatedAt, and providers that report nothing until the turn ends
left the agent listed as idle for the whole turn.

Forward the agent manager's current state instead.

Closes #8022

* test(server): drain agent persistence before fixture cleanup

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 20:52:07 +00:00
f0dfb9134c fix(app): keep the open workspace visible in collapsed groups (#8395)
* fix(app): keep the open workspace visible in collapsed sidebar groups

A collapsed group showed its first seven rows plus working and
needs-attention rows, so an idle open workspace past the limit was
hidden behind Show more and no sidebar row was highlighted.

selectSidebarGroupVisibility now takes the route selection. When the
collapsed limit would hide the open workspace, it is returned as
leadingItem and rendered first in project groups, status groups and
both Pinned sections. Rows the group already shows stay in place,
expanded groups keep group order, and drag lists render the leading
row outside their sortable data, so a reorder never saves its borrowed
position.

Refs #8078

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): keep shortcut targets visible in collapsed groups

The navigation journey still required the selected overflow workspace to stay hidden. Assert its visible selection while preserving collapsed-group navigation and the seven ordinary rows.

Co-authored-by: 0xRoy <1997roylee@gmail.com>

---------

Co-authored-by: 0xRoy <1997roylee@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 20:03:31 +00:00
paseo-bot[bot]andJeong-Uk,Kim 56c051503b fix(server): keep promptless Codex agents usable after reopening (#8393)
* fix(server): report a Codex thread only once Codex can resume it

Codex writes a new thread's rollout at its first turn, but the adapter
starts a thread when the agent is created and reported its ID at once.
AgentManager stored that ID as the persistence handle, so a Codex agent
that was reopened before its first prompt (idle runtime release, daemon
restart, plugin tool reload) failed every prompt with "no rollout found".

The adapter now reports a thread's ID, as runtime info and as a handle,
only once it is resumable: after Codex announces the thread's first
turn, or when the thread was resumed or forked. Until then a reopened
agent starts a new session, as an unprompted Claude agent does.

Document that a persistence handle must be resumable.

* test(server): reopen promptless Codex agents without model requests

Exercise the real app-server before any first turn, so publishing an unsaved persistence handle fails at the actual resume operation.

Co-authored-by: Jeong-Uk,Kim <dnrtoki@gmail.com>

---------

Co-authored-by: Jeong-Uk,Kim <dnrtoki@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 18:56:22 +00:00
paseo-bot[bot] 85354fcb6a test(terminal): verify cleanup by process identity (#8389)
* test(terminal): capture recurring worker publication failures

* test(terminal): diagnose the remaining process cleanup wait

* test(terminal): verify cleanup by process identity

* test(terminal): retain native liveness proof during cleanup checks

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 18:38:48 +00:00
QTomandClaude Opus 5.5 2dd931c8bd fix(app): accept host:port and URLs in the direct connection host field (#8223)
The direct connection form treated any host containing a colon as a bare
IPv6 address, so entering "100.66.69.36:6767" or "tcp://host:6767" built
"tcp://[100.66.69.36:6767]:6767" and surfaced the browser's raw "Invalid URL"
error. Normalize the host input first: strip a leading scheme and any
path/query/fragment, split "host:port" (the inline port wins), and only treat
unbracketed hosts with two or more colons as IPv6.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:12 +00:00
QTomandClaude Opus 5.5 58eafd81e5 fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230)
Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:01:27 +00:00
paseo-bot[bot]andhuazhongmin eab9ac3b03 fix(acp): include late-discovered skills in initial command lists (#8387)
* fix(providers): wait for the ACP command inventory to settle

An ACP agent publishes its slash-command inventory in batches. MiniMax
Code sends its built-in commands first, then merges in every discovered
skill roughly 350ms later. `listCommands()` settled on the first batch,
so a session that had to discover its commands at creation time offered
the 10 built-in commands instead of the 396 the agent eventually
publishes. Every skill was missing from `/` until the session was
reopened and the cached inventory was read again.

Wait for the inventory to go quiet (600ms without another batch) instead
of for the first batch. An empty batch still settles immediately, and
`initialCommandsWaitTimeoutMs` (1500ms default) still bounds the whole
wait for providers that publish once or never publish at all.

* test(acp): verify late commands through the provider boundary

Exercise initial command discovery over real stdio, including a first batch cached before session creation returns. Keep the later skill and subsequent cached read in the same public session contract.

Co-authored-by: huazhongmin <peanut_hzm@163.com>

---------

Co-authored-by: huazhongmin <peanut_hzm@163.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 16:07:08 +00:00
paseo-bot[bot]andpeanut_hzm e02fa9a5f8 fix(acp): report provider startup exits without waiting for timeout (#8384)
* fix(server): resolve ACP providers installed after the daemon started

Windows hands a new process the PATH its parent had when it started. The
daemon's supervisor therefore keeps the PATH it inherited at launch, and any
CLI installed afterwards stays invisible to it. A provider installed after the
daemon started reported "not installed" forever, even though the binary was
on PATH in a fresh shell.

Refresh the process PATH from the Windows user/machine registry environment
when the daemon worker starts. Existing entries keep their order and take
precedence; registry entries are appended. This runs once at startup so
PATH-isolated availability checks and explicit env overrides stay
authoritative.

Spawn the absolute path the launcher resolver verified rather than the bare
command. The resolver already finds installs outside the daemon's inherited
PATH (Winget packages, directories added after startup); the resolved path
was previously discarded.

An ACP agent that exits during the handshake never rejects the SDK's
initialize request, so the provider refresh waited out its full 120s deadline
and reported "Timed out refreshing <provider> after 120000ms; pending:
initialize" instead of the child's stderr. Race a child-exit rejection
against initialize so the real failure surfaces.

Also repair persisted MiniMax Code commands. The first catalog entry ran
`npx -y @minimax-ai/code@<version> acp`; npx derives the executable name from
the package name (`code`), but the package publishes `mcode`, so npm exited
before the ACP handshake. The catalog was fixed separately, but commands
already persisted by an older client were never migrated.

* fix(acp): report startup exits across probes and sessions

Extend the shared process failure handler so initialize rejects with stderr after the child exits, including session creation. Keep this fix scoped to startup error reporting; remove the separate PATH and persisted-command changes.

Co-authored-by: peanut_hzm <peanut_hzm@163.com>

* fix(acp): validate provider options before starting probes

Reject malformed options before creating a child or its process-failure promise, so cleanup cannot cause an unhandled rejection in the daemon.

---------

Co-authored-by: peanut_hzm <peanut_hzm@163.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 14:55:17 +00:00
b117e3fb0f fix(claude): allow conversation rewind to messages sent during a turn (#8372)
* fix(server): let a message steered into a running Claude turn be rewound to

A steered message never got a provider message id, because Claude takes it
into the running turn without echoing it back, so rewinding to it refused
with "Cannot rewind before the provider acknowledges the submitted prompt".
The steer admission now reports the uuid Claude minted for the message, and
a Claude conversation rewind to it forks at the entry Claude read it after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): keep messages steered into a running Claude turn in the replayed timeline

Claude records a steered message as a queued_command attachment rather than a
user entry, and the history replay skipped it, so every such message, replies
from other agents included, vanished from the timeline after a daemon restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): verify steer identities across lifecycle tests

Match the accepted-steer contract used by conversation rewind and verify that its identity names the message delivered to the provider.

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>

* fix(claude): release transcript streams after rewind lookup

Closing readline leaves a partially read transcript open after an early match. Destroy the owned stream on exit and cover repeated lookups with unread data.

---------

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 13:03:27 +00:00
paseo-bot[bot] 2ac288f4d0 test(plugins): make global switch recovery deterministic (#8342)
* test(plugins): trace recurring global recovery failure

* test(plugins): control cleanup in global switch recovery

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 12:26:32 +00:00
Mohamed Boudra 1008e9fffc Insert suggested commands into the composer (#8371)
* feat(composer): insert suggested commands into drafts

* fix(composer): close suggestions after command insertion

* fix(composer): place caret after inserted commands
2026-10-31 19:25:16 +07:00
Matt CowgerandMohamed Boudra 74613e4bc3 feat(opencode): reuse running OpenCode v2 services (#7435)
* feat(opencode): connect v2 client to an existing server

* feat(providers): reuse running agent servers

Keep shared server sessions isolated and retain native tools while refreshes reuse the running service.

* fix(providers): recognize formatted bridge manifests

Managed plugin ownership must survive harmless JSON formatting changes.

* test(providers): distinguish isolated session bindings

Use separate session identities to verify that dedicated helpers keep their environments isolated.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-31 18:55:24 +07:00
paseo-bot[bot]andOuyang Xingyuan 0e5bcbd9f5 fix(app): open relative chat links inside nested repositories (#8361)
* fix(app): open a nested-repo file when the workspace-root path is missing

A workspace that contains another git repo often gets paths written
relative to that nested repo. `schema/x.md` is really
`repo-a/schema/x.md`. A markdown link was joined onto the workspace
root and opened a missing file. Inline code already asked the daemon
for a suffix match.

Resolved workspace-relative paths that contain a slash now use that
same lookup before opening. An exact hit stays on the original path.
A suffix hit replaces it and keeps the line range. A failed lookup
still opens the original path. Bare names, absolute paths, and `~/`
paths are unchanged.

Example:
- Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md`
- Before: open `/repo/schema/x.md` (missing)
- After: open `/repo/repo-a/schema/x.md`

* fix(app): preserve explicit destinations when finding nested files

Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* fix(app): keep network share links on their absolute destination

Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths.

* fix(app): keep nested-file lookup inside ordered path opening

Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection.

* test(app): load UI modules before test lifecycle hooks

Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 11:05:06 +00:00
a7d9bb34fc fix(plugins): keep rewound conversations on their retained branch (#8368)
Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged.

Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch.

The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening.

Closes #4667
Supersedes #4668 and the rewind-history proposal in #4754.

Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
2026-10-31 17:48:42 +07:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
Mohamed Boudra 254166e7c1 feat(terminal): add native selection handles (#8348)
* feat(terminal): add native selection handles

* fix(terminal): preserve selection at row boundaries

* fix(terminal): preserve selection boundary when handles cross
2026-10-31 14:23:10 +07:00
paseo-bot[bot] 9c5b47421a test(server): exclude filesystem startup from config responsiveness (#8296)
* test(server): diagnose recurring config responsiveness failure

* test(server): compare profiled and plain config reads on Windows

* test(server): correlate config profiles with long callbacks

* test(server): probe config timing under CPU contention

* test(server): exclude filesystem pool startup from config budget

* test(terminal): finish output before comparing replay captures

A marker can arrive before its trailing newline. Comparing captures taken on either side of that output compares different terminal states. Split that output deliberately and wait for its parsed completion before capturing the expected replay.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 06:49:47 +00:00
paseo-bot[bot]andMohamed Boudra 3b51b8ab2e Recover terminal worker records after Windows reader contention (#8155)
* test(terminal): let cleanup fixture exit on request

* test(terminal): capture the Windows record rewrite failure

* test(terminal): repeat Windows cleanup exit diagnostics

* fix(server): reopening a Codex agent no longer reads and holds its full history (#8132)

* fix(server): read Codex history only when Paseo replays it

Reopening a Codex agent whose timeline Paseo still held read the full
thread on connect and kept it on the session until the next replay, which
never came, so every reopened agent held a second copy of its transcript.
thread/resume also returned every turn, and only the sandbox policy was
used from it.

Resume now sends excludeTurns (older Codex ignores the unknown field) and
the history read moves into streamHistory, whose caller owns the result.
A resumed session still needs the earlier turns for rewind targets and the
sub-agent routes that keep an earlier child's interactions on its original
card, so the first turn, goal or rewind indexes the thread and drops the
timeline it read.

* test(server): run reopened Codex agent tests without the Codex CLI installed

* test(terminal): isolate record reader contention on exit

* feat(usage): show when each limit resets, and make usage rows easier to read (#8162)

The reset countdown floored to one unit, so a weekly window read "resets 2d"
from 2d 23h down to 2d 0h and looked stuck for a day; the session window had
the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"),
and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM",
"Oct 31, 3:00 PM"), so users can plan work around a reset without hovering.

Rows now put the window name and percent on one line, the bar under it, and the
reset on its own full-width line, so the exact time fits at phone width in
longer languages. The pinned-row background is gone; the pin glyph, drawn in
the foreground colour when pinned, is the only pinned signal. Bars are 6px.

* 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.

* Deliver a steer message that arrives while the previous turn is still starting (#6192)

* Deliver a steer message that arrives while the previous turn is still starting

A steer send that reached an agent before its previous turn had a turn id
found no turn to steer and replaced the starting turn, so the earlier
message never reached the provider. Steering now waits for the starting
turn to be accepted (or fail) and steers the turn it became.

Fixes #6186

* Wake a waiting steer only once the starting turn has an id or its run has ended

A failed start used to wake the waiting steer before its run was cleared,
leaving the later send to replace a run that had already failed.

* Clear a failed start's run even when reporting the failure throws

A steer waiting on that start is released only when the run is cleared.

* Test that a failed start keeps a waiting steer until its run is cleared

* fix(server): steer Codex turns while native identification is pending

Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>

* fix(terminal): recover worker records after reader contention

Retain the latest record until a transient sharing failure clears. Deliver exits while publication is pending, await publication before confirming creation, and cancel retries before retirement.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-31 06:31:08 +00:00
paseo-bot[bot]andEgg3901 9b3f1bfffd Start custom Codex providers with reserved names (#6954)
* fix(codex): start custom providers with reserved names

Codex rejects custom provider keys such as ollama before a turn starts.
Namespace the generated keys without changing user-facing provider ids.
Keep older sessions discoverable and select the new key explicitly when
resuming them, so the startup fix does not strand existing conversations.

Closes #6874

Co-authored-by: Egg3901 <rainfordmason@gmail.com>

* fix(codex): keep native sessions out of custom provider imports

* fix(providers): align custom provider example with generated config

Correct the documented internal provider key and simplify import filtering while preserving native and legacy session selection.

* fix(codex): exclude native GCP sessions from custom imports

* test(codex): seed sessions through the portable launcher

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Egg3901 <rainfordmason@gmail.com>
2026-10-31 05:02:14 +00:00
paseo-ai[bot] 5ea639d9b2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-31 04:01:29 +00:00
Mohamed Boudra 9c926ed126 fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282)
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering.

Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering.

All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven.

Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change.

Closes #6137.
Closes #6147.
2026-10-31 10:51:45 +07:00
Mohamed Boudra 7b03f0ad21 fix(ci): migrate to the latest macOS runners (#8335)
* fix(ci): replace retiring macOS 14 runners

Use the supported macOS 15 ARM64 image before the November 2 retirement. Keep the Intel release runner and existing test/build/release commands unchanged.

* fix(ci): use the latest macOS runner images
2026-10-31 10:05:16 +07:00
Mohamed Boudra 0a549945b5 fix(terminal): keep native text aligned with terminal cells (#8336)
Shape rows in native Canvas and CoreText, then place clusters on xterm columns so fallback font advances cannot separate text from its cursor. Wait for measured dimensions before attachment and repaint overhanging ink with affected rows.

Use Android platform ICU to avoid bundling approximately 14 MB of data; retain and document the Android 10 conjunct limitation.
2026-10-31 09:45:57 +07:00
Mohamed Boudra 0be49c0620 feat(transport): keep large messages intact across relay connections (#8302)
Negotiate shared binary chunk framing below application messages and routing, with bounded logical assembly and paced writes that respect encrypted relay limits. Preserve legacy admission, reconnect state, sibling route isolation, and native single-frame upload behavior.
2026-10-31 09:26:24 +07:00
paseo-bot[bot] 186c132151 test(server): isolate the reconciliation responsiveness budget (#8317)
* test(server): isolate the reconciliation responsiveness budget

Measure the cold reconciliation in compiled plain Node so unrelated runner callbacks cannot consume its 20ms budget. Retain real filesystem admission and every archival assertion, with unrelated CPU work in the parent as a regression check.

* test(server): initialize probe filesystem support before measurement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 00:33:29 +00:00
Mohamed Boudra a4f696620b Keep big agents loadable by bounding every timeline item (#8274)
* fix(server): bound every timeline item so one fetch cannot close the socket

A timeline page is one WebSocket message. Only three tool-call fields were
capped, so MCP results, file contents, errors and merged assistant rows could
make a 20-entry or even a 1-entry fetch pass the outbound high-water mark.

limitAgentTimelineItemContent is now one budgeted walk over any item: 64 KiB
for tool calls, 256 KiB for other items. Over budget, the largest strings are
cut to a shared length with a visible marker and oversized data URLs become a
marker. Fetches also apply it to merged assistant and reasoning rows.

* fix(server): keep short strings whole and typed arrays valid when limiting items

Strings up to 1 KiB are never cut, so a tool call keeps its callId and an
item limits to the same result twice. Arrays drop trailing elements
without inserting a marker element, which typed arrays such as todo items
would reject.
2026-10-30 15:27:27 +01:00
Mohamed Boudra 184ab66cde Show sub-agents as launch markers in the parent timeline (#8276)
* fix: make sub-agent cards launch markers

Sub-agent tool calls no longer accumulate the child's activity into
`log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child
starts and when its label or status changes; the child's messages and
tool calls stay in the sub-agent's own timeline. The app no longer
renders a sub-agent log.

The wire schema makes `log` optional. Daemons still send `log: ""` so
apps that require the field keep parsing the card.

* fix(server): put a tintinweb foreground answer in the child timeline

A foreground @tintinweb/pi-subagents child has no session file, so its
answer only exists in the Agent tool result. Emit that text as the
child's assistant message when the child finishes. Tag every sub_agent
producer's `log: ""` with COMPAT(subagent-log) so the shim removal
finds them.

* fix(server): settle child compactions and keep sub-agent cards stable

- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.

* fix(server): keep a gotgenes foreground answer on replay

A foreground @gotgenes/pi-subagents child finds its session file only
through the live runtime marker, which history does not keep. Without a
file, emit the spawn result text as the child's assistant message.
2026-10-30 21:26:38 +07:00
paseo-ai[bot] fbe24fcdb6 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-30 11:06:08 +00:00
Mohamed Boudra 787edb1bf9 Respect proxy variables throughout the daemon (#8177)
* fix(server): respect environment proxies across daemon traffic

Install proxy routing at daemon and plugin process startup, with a separate HTTP agent for outbound WebSockets. Always exempt loopback so local provider bridges remain reachable.

* test(server): run usage source regressions in CI

* fix(server): validate proxy settings without blocking startup

Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture.

* refactor(server): treat ignored proxy variables as unset

* fix(server): read Windows proxy environment entries once

* fix(server): apply proxy policy to connected host links

* test(server): isolate proxy fixture credential directories

* fix(server): keep proxy routing and authentication consistent

Normalize Windows environment names and wildcard bypass rules at the network boundary. Compare bypass ports numerically and keep Basic proxy credentials on the CONNECT pool, including empty credential fields.
2026-10-30 17:57:57 +07:00
Mohamed Boudra 385c29b655 Keep failed chat history available for retry (#8278)
* fix(app): keep failed chat history until retry

Automatic retries repeatedly reopen provider sessions after persistent failures. Keep the error visible until the user retries, while retaining reconnect recovery.

* fix(app): catch up chats after subscription failure

A failed replacement subscription leaves chats unsubscribed, so discard its old catch-up state and fetch missed history after retry.

* fix(app): recover gaps during history retry

Keep gap recovery active while a user-initiated retry is running so the chat is not marked current before its follow-up fetch.

* fix(app): show history errors in archived chats

Cached archived chats need the same manual retry affordance when history synchronization fails.
2026-10-30 17:57:52 +07:00
Mohamed Boudra 9477fe0c00 fix(app): refresh Android layout after background fold changes (#8285)
Backport the Unistyles 3.5.3 Activity configuration fix while keeping 3.2.4 and its existing fixes. Application resources could leave the responsive shell on the previous fold size after resume. Cover the real device-state sequence with a native UI regression.
2026-10-30 17:22:53 +07:00
531370c4ce fix(pi): show tool-result images live and after reload (#8280)
* pi: render tool-result images as inline image rows (paseo #1717 parity)

* fix(pi): preserve turn ownership while rendering images

* fix(pi): preserve metadata when stripping nested image payloads

---------

Co-authored-by: be-my-bot <bot@be-my-code.local>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: duyiliu <duyiliu@users.noreply.github.com>
2026-10-30 10:10:39 +00:00
paseo-bot[bot] e299b21e94 test(server): measure Git filter work within its callback (#8270)
Exclude unrelated callbacks and fixture garbage from the cold event-filter budget. Keep the 20ms limit and IO assertions, with unrelated CPU work as a regression control.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-30 09:17:50 +00:00
PeanutandPeanut-HZM 1f79efcd62 fix(providers): name the MiniMax Code binary so npx can resolve it (#8201)
The MiniMax Code catalog entry ran `npx -y @minimax-ai/code@0.6.13 acp`.
npx derives the executable from the unscoped package name, which is `code`,
but the package publishes `mcode` and `mcode-tools`. npm therefore exits
with "could not determine executable to run" before the agent starts.

Paseo reports this as a provider that timed out during `initialize`, so the
entry looks like a broken agent rather than a malformed command.

Name the package and the binary separately. The ACP handshake then completes
and the provider reports its models and modes.

Co-authored-by: Peanut-HZM <peanut@hzm.me>
2026-10-30 08:08:14 +00:00
Mohamed Boudra e3682ba18c Usage fetches only when shown, and can be turned off (#8181)
* fix(usage): fetch only when shown and add a host-wide off switch

* fix(usage): restore policy defaults and isolate usage test state

* refactor(usage): give the sidebar footer sole ownership of its summary
2026-10-30 14:32:19 +07:00
441 changed files with 26078 additions and 7159 deletions
+2 -2
View File
@@ -281,10 +281,10 @@ jobs:
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to # otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime. # bound runtime.
server-tests-macos: server-tests-macos:
name: server-tests (macos-14, file observation) name: server-tests (macos-latest, file observation)
needs: changes needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14 runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+28 -2
View File
@@ -100,10 +100,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- runner: macos-14 - runner: macos-latest
electron_arch: arm64 electron_arch: arm64
node_heap_mb: 4096 node_heap_mb: 4096
- runner: macos-15-intel - runner: macos-26-intel
electron_arch: x64 electron_arch: x64
node_heap_mb: 8192 node_heap_mb: 8192
permissions: permissions:
@@ -135,6 +135,32 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag - name: Set desktop package version from tag
shell: bash shell: bash
run: | run: |
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
run: nix build .#desktop -o result-desktop run: nix build .#desktop -o result-desktop
build-desktop-darwin: build-desktop-darwin:
runs-on: macos-14 runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+72 -6
View File
@@ -1,15 +1,81 @@
# Changelog # Changelog
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them. ## 0.15.0-beta.1 - 2026-11-01
Added: Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
- 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)) Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
- 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: Worktree setup can copy ignored files and use plugin-defined locations before agents start.
- 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)) ### Added
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### Improved
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### Plugins
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
### Fixed
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
## 0.14.0 - 2026-10-29 ## 0.14.0 - 2026-10-29
+73 -7
View File
@@ -1,15 +1,81 @@
# 更新日志 # Changelog
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。 ## 0.15.0-beta.1 - 2026-11-01
新增: daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100)) 移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进: worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) ### 新增
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### 改进
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### 插件
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
### 修复
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
## 0.14.0 - 2026-10-29 ## 0.14.0 - 2026-10-29
+1
View File
@@ -52,6 +52,7 @@ 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/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/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-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/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/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 | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install ## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing [Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
+10
View File
@@ -493,6 +493,16 @@ 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). 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 ## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
+33 -3
View File
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
## Table of Contents ## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider) - [Extending a built-in provider](#extending-a-built-in-provider)
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
@@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
--- ---
## OpenCode with an existing v2 server
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
```json
{
"agents": {
"providers": {
"opencode": {
"env": {
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
}
}
}
}
}
```
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
---
## Z.AI (Zhipu) coding plan ## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
@@ -232,7 +262,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`. 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. 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.
### Setup ### Setup
@@ -260,9 +290,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: Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml ```toml
model_provider = "my-codex" model_provider = "paseo:my-codex"
[model_providers.my-codex] [model_providers."paseo:my-codex"]
name = "My Codex" name = "My Codex"
base_url = "https://custom-relay.example.com/v1" base_url = "https://custom-relay.example.com/v1"
wire_api = "responses" wire_api = "responses"
+1
View File
@@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **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`). - **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`). - **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`. - **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`). - **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`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
+14
View File
@@ -35,6 +35,20 @@ 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. [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 For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run: with an empty prompt, hide its keyboard, and run:
+1 -1
View File
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons. 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.
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. 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.
## Contribute sidebar items ## Contribute sidebar items
+41
View File
@@ -52,6 +52,47 @@ 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 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. 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 observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned `owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
+50 -4
View File
@@ -30,6 +30,50 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples. for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools. server and tool identity without approving native tools.
@@ -89,7 +133,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events. Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
@@ -115,7 +159,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading. Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state. Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -478,7 +522,7 @@ interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[]; readonly controls?: AgentOptionControl[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -500,13 +544,15 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>; setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>; applyOptions?(options: ProviderOptions): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): { tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>; run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null; } | null;
} }
``` ```
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation. `setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps ### Steps
+10 -6
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) pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity) → headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal) → TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ process.send IPC → daemon main process → framed local socket → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts) → TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes) → binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime → client decode (daemon-client.ts) → stream router → emulator runtime
@@ -19,11 +19,15 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## Invariants (the easy-to-break ones) ## 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. - **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 `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. - **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.
- **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). - **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 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. - **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.** 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. - **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.
- **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. - **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. - **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. - **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.
+75
View File
@@ -0,0 +1,75 @@
# 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. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct 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 clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. 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.
## Character widths
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨‍👩‍👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
## 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.
+15 -1
View File
@@ -15,6 +15,8 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again). - 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. - 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. 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 ## Record, socket and secret
@@ -40,10 +42,22 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it. 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. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that. **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. 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 ## 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: 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:
+10 -4
View File
@@ -21,10 +21,16 @@ The invariants are:
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable > agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination. > through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced Every item is bounded before it enters either delivery path, because a timeline page is one
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events. WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized 64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
tool payload. `…[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.
## Presence is not delivery ## Presence is not delivery
+1 -1
View File
@@ -1 +1 @@
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw= sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
+106 -73
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.14.0", "version": "0.15.0-beta.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.14.0", "version": "0.15.0-beta.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -14740,10 +14740,52 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.331",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.331.tgz",
"integrity": "sha512-ShBTb3QF0ul/i55lfRdJpcu0hlA4aJdqRdzzHdOCws2WUzSsG0SCfMhLjzaoO1dVUGNj2Z7PNLqiXnlyqOinbg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"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/addon-unicode-graphemes": {
"version": "0.5.0-beta.330",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.5.0-beta.330.tgz",
"integrity": "sha512-Zoiv/8tmku4LcU7bEL6zer+DNN6p6F8VCkyviSmbDxpA/W9zSnWDiD/ZpwFDQqegwjSptGzBNMhW2Os1sa+lUw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.329",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.329.tgz",
"integrity": "sha512-bwqMma7X4cCBfwmWqKuhxNLkIXKW0lJTapaQ0g/9Kq7n6D6VCL4dYQCQ6DoeHv27ncJD4WzeoDTCEeSn0cspfg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.336"
}
},
"node_modules/@xterm/headless": { "node_modules/@xterm/headless": {
"version": "6.0.0", "version": "6.1.0-beta.334",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz",
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", "integrity": "sha512-QkwuIeP9dXlk/eOsAbqX8AvtS/W8EhRo6dkl2YYZr+jWAWSfDDMi+zPlegWUflSdsRKMURMvzvvkVZr6GztK4g==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.1.0-beta.336",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.336.tgz",
"integrity": "sha512-QrvZZsffXsLW4Z3sba9GqimLvVwlb8b3W1NwZ+l6DdRnpH5pg5cFGWg2ceIburmfaGQ07YJHNekwxxlTcHZ3BA==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"addons/*" "addons/*"
@@ -37590,7 +37632,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37620,13 +37662,14 @@
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213", "@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213", "@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213", "@xterm/addon-image": "0.10.0-beta.331",
"@xterm/addon-ligatures": "^0.11.0-beta.213", "@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213", "@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291", "@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.292", "@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"expo": "~57.0.30", "expo": "~57.0.30",
"expo-asset": "~57.0.20", "expo-asset": "~57.0.20",
@@ -37703,7 +37746,8 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7", "@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7", "@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213", "@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16", "babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
@@ -38482,15 +38526,6 @@
"@xterm/xterm": "^6.1.0-beta.213" "@xterm/xterm": "^6.1.0-beta.213"
} }
}, },
"packages/app/node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz",
"integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-ligatures": { "packages/app/node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.213", "version": "0.11.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
@@ -38534,34 +38569,6 @@
"@xterm/xterm": "^6.1.0-beta.213" "@xterm/xterm": "^6.1.0-beta.213"
} }
}, },
"packages/app/node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.291",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.292"
}
},
"packages/app/node_modules/@xterm/headless": {
"version": "6.1.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz",
"integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==",
"dev": true,
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/@xterm/xterm": {
"version": "6.1.0-beta.292",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/babel-plugin-syntax-hermes-parser": { "packages/app/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.36.1", "version": "0.36.1",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
@@ -39793,12 +39800,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0", "@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.14.0", "@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.14.0", "@getpaseo/server": "0.15.0-beta.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -40045,10 +40052,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.14.0", "@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.14.0", "@getpaseo/relay": "0.15.0-beta.1",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -40060,7 +40067,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.14.0", "version": "0.15.0-beta.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -40304,7 +40311,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0", "version": "0.15.0-beta.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -40350,7 +40357,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40386,15 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^1.4.0", "@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.14.0", "@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.14.0", "@getpaseo/protocol": "0.15.0-beta.1",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -40397,8 +40404,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.14.0", "@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.14.0", "@getpaseo/protocol": "0.15.0-beta.1",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -40420,7 +40427,7 @@
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"semver": "^7.8.5", "semver": "^7.8.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -40447,7 +40454,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0", "@noble/curves": "^2.4.0",
@@ -40668,21 +40675,23 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0", "@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.14.0", "@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.14.0", "@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.14.0", "@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.14.0", "@getpaseo/relay": "0.15.0-beta.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10", "@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0", "@xterm/addon-serialize": "0.14.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78", "ai": "5.0.78",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -40690,6 +40699,7 @@
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"express": "^4.18.2", "express": "^4.18.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
"markdown-it": "^10.0.0", "markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7", "mnemonic-id": "^3.2.7",
@@ -40707,6 +40717,7 @@
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2", "tree-kill": "^1.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"which": "^5.0.0", "which": "^5.0.0",
"ws": "^8.14.2", "ws": "^8.14.2",
@@ -41280,6 +41291,15 @@
"url": "https://opencollective.com/vitest" "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": { "packages/server/node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41349,6 +41369,19 @@
"@esbuild/win32-x64": "0.25.12" "@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": { "packages/server/node_modules/isexe": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -41598,7 +41631,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43241,7 @@
}, },
"plugins": { "plugins": {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.14.0", "version": "0.15.0-beta.1",
"dependencies": { "dependencies": {
"@getpaseo/plugin": "*", "@getpaseo/plugin": "*",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.14.0", "version": "0.15.0-beta.1",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises"; import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { test, expect, type Page } from "../support/fixtures"; import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
await agent.cleanup(); await agent.cleanup();
} }
}); });
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
await page.getByRole("link", { name: linkName, exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
content,
);
}
async function returnToNestedLinks(page: Page): Promise<void> {
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
}
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "nested-file-link-",
title: "Nested file links",
featureValues: {
mockStreamingAssistantResponse:
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
await writeFile(
path.join(agent.cwd, "repo-a/schema/x.md"),
"# Nested file content\n\nDetails\n",
);
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
await readLinkedGuide(page, "nested guide", "Nested file content");
await returnToNestedLinks(page);
await readLinkedGuide(page, "exact guide", "Exact file content");
await returnToNestedLinks(page);
await page.getByRole("link", { name: "missing guide", exact: true }).click();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -1,5 +1,9 @@
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; import {
attachFileFromMenu,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import { import {
openAgentRoute, openAgentRoute,
seedMockAgentWorkspace, seedMockAgentWorkspace,
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
} }
test.describe("Composer autocomplete", () => { test.describe("Composer autocomplete", () => {
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
const requests = recordCommandRequestsByAgentId(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "suggested-command-",
title: "Suggested commands",
initialPrompt: "Suggest `/mock` without running it",
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
});
try {
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
await expect(message).toBeVisible();
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
await expect.poll(() => requests.length).toBe(1);
await attachFileFromMenu(page, {
name: "note.txt",
mimeType: "text/plain",
buffer: Buffer.from("attachment"),
});
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await message.getByRole("link", { name: "/mock", exact: true }).click();
await expect(composerLocator(page)).toHaveValue("/mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await message.getByRole("link", { name: "Run mock" }).click();
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await composerLocator(page).fill("draft");
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
element.setSelectionRange(0, 0),
);
await message.getByRole("link", { name: "/mock", exact: true }).click();
await composerLocator(page).pressSequentially("argument");
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
await composerLocator(page).fill("/");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toContainText("/mock");
expect(requests).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("keeps New workspace off the running-agent command list before a project is chosen", async ({ test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page, page,
}) => { }) => {
@@ -18,7 +18,7 @@ function visibleMainPane(page: Page) {
} }
function composerChangesPill(page: Page) { function composerChangesPill(page: Page) {
return page.getByTestId("composer-diff-stat-pill"); return page.getByRole("button", { name: "Open Changes tab", exact: true });
} }
async function revealComposerChangesInExplorer(page: Page) { async function revealComposerChangesInExplorer(page: Page) {
@@ -498,6 +498,7 @@ test.describe("send review feedback", () => {
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({ test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
page, page,
}) => { }) => {
test.setTimeout(120000);
const workspace = await seedChangedAgent("review-feedback-multiple-"); const workspace = await seedChangedAgent("review-feedback-multiple-");
const other = await seedChangedAgent("review-feedback-other-"); const other = await seedChangedAgent("review-feedback-other-");
try { try {
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises"; import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test } from "../support/fixtures"; import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { import {
openDaemonOverview, openDaemonOverview,
@@ -13,6 +14,10 @@ import {
readWorkerPid, readWorkerPid,
openLongLivedTerminal, openLongLivedTerminal,
listTerminalIds, listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle"; } from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({ test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
); );
return paseoHome; 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();
}
},
);
@@ -81,6 +81,8 @@ for (const folder of [
"docs/guides", "docs/guides",
"docs/guides/", "docs/guides/",
"docs/指南", "docs/指南",
"examples/next.js/",
"examples/next.js",
]) { ]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({ const session = await seedMockAgentWorkspace({
@@ -92,6 +94,9 @@ for (const folder of [
try { try {
await mkdir(path.join(session.cwd, folder), { recursive: true }); await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
await mkdir(path.dirname(competingFile), { recursive: true });
await writeFile(competingFile, "This is a file, not the requested folder.\n");
await session.client.waitForFinish(session.agentId, 30_000); await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
@@ -1,3 +1,4 @@
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
import { import {
composerLocator, composerLocator,
enterComposerFullscreen, enterComposerFullscreen,
@@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
try { try {
await startWithoutRememberedModel(page);
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, { await openNewWorkspaceComposer(page, {
@@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => {
projectDisplayName: project.projectDisplayName, projectDisplayName: project.projectDisplayName,
}); });
await chooseModel(page, "mock", "Ten second stream");
const composer = page.getByRole("textbox", { name: "Message agent..." }); const composer = page.getByRole("textbox", { name: "Message agent..." });
const createButton = page.getByTestId("workspace-create-submit"); const createButton = page.getByTestId("workspace-create-submit");
await expect(composer).toBeEditable({ timeout: 30_000 }); await expect(composer).toBeEditable({ timeout: 30_000 });
@@ -38,6 +38,9 @@ import {
fillDirectHostUri, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostFormValues,
expectDirectHostSslEnabled, expectDirectHostSslEnabled,
expectDirectHostSslDisabled,
fillDirectHostAddress,
toggleDirectHostSsl,
expectDirectHostUriValue, expectDirectHostUriValue,
expectDirectHostUriHidden, expectDirectHostUriHidden,
expectDiagnosticsContent, expectDiagnosticsContent,
@@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => {
await expectAddHostMethodOptions(page); await expectAddHostMethodOptions(page);
await selectHostConnectionType(page, "direct"); await selectHostConnectionType(page, "direct");
await fillDirectHostAddress(page, "example.paseo.test", "7443");
await expectDirectHostSslDisabled(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslEnabled(page);
await toggleHostAdvanced(page);
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
await toggleHostAdvanced(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslDisabled(page);
await toggleHostAdvanced(page); await toggleHostAdvanced(page);
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret"); await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page); await toggleHostAdvanced(page);
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
await seed.cleanup(); await seed.cleanup();
} }
}); });
function workspaceRows(page: Page, ids: string[]) {
return page.locator(
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
);
}
async function openWorkspaceRow(page: Page, workspaceId: string) {
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
}
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceSort(page, "Created");
const { rows, group } = compactProjectRows(page, seed);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await revealWorkspaces(group, "Show more");
await openWorkspaceRow(page, ids[9]!);
await expectWorkspaceOrder(rows, ids);
await revealWorkspaces(group, "Show less");
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await openWorkspaceRow(page, ids[2]!);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
).toHaveAttribute("aria-selected", "true");
} finally {
await seed.cleanup();
}
});
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
const projectKey = projectEquivalenceViewKey(seed.projectKey);
await restorePreBetaOrder(
page,
projectKey,
ids.map((id) => `${getServerId()}:${id}`),
);
await selectWorkspaceSort(page, "Manual");
const rows = workspaceRows(page, ids);
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await toggle.click();
await openWorkspaceRow(page, ids[9]!);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
} finally {
await seed.cleanup();
}
});
@@ -1,5 +1,10 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window { interface AttachOverlayProbeWindow extends Window {
@@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false); 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);
});
}
}); });
@@ -0,0 +1,69 @@
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,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath); ).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal"); 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);
});
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8], workspaceId: order[8],
}); });
}); });
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => { await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
await expectHiddenWorkspaceShortcuts(page, order); await expectCollapsedWorkspaceShortcuts(page, order);
}); });
} finally { } finally {
await project.cleanup(); await project.cleanup();
} }
}); });
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) { async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
await page.getByLabel("Show less", { exact: true }).click(); await page.getByLabel("Show less", { exact: true }).click();
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(7); await expect(rows).toHaveCount(8);
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await expect(rows).toHaveCount(7);
await page.keyboard.press("Alt+9"); await page.keyboard.press("Alt+9");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await expect(rows).toHaveCount(8);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
await expect(rows).toHaveCount(8);
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
0, 0,
); );
await page.keyboard.press("Alt+]"); await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
} }
+104
View File
@@ -0,0 +1,104 @@
// 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");
}
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
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,
});
// The app's buffer uses grapheme widths; the expected cells must too.
terminal.loadAddon(new UnicodeGraphemesAddon());
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 };
}
@@ -0,0 +1,59 @@
// 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");
}
@@ -0,0 +1,423 @@
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;
}
@@ -0,0 +1,236 @@
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;
@@ -0,0 +1,149 @@
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;
@@ -0,0 +1,197 @@
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,5 +1,5 @@
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises"; import { writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
throw error; 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,4 +1,11 @@
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process"; import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -6,6 +13,8 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env"; import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node"; import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon { export interface IsolatedHostDaemon {
serverId: string; serverId: string;
port: number; port: number;
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server") ? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../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 spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = { const spawnOptions: SpawnOptions = {
cwd: serverDir, cwd: serverDir,
@@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
detached: false, detached: false,
}; };
const child = publishedPackageRoot const spawned = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions) ? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions); : spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = ""; let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => { spawned.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8"); stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n"); stderr = stderr.split("\n").slice(-40).join("\n");
}); });
try { try {
await waitForServer(port, child); await waitForServer(port, spawned);
return child; return spawned;
} catch (error) { } catch (error) {
await killProcessTree(child);
throw new Error( throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error }, { cause: error },
@@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon(
} }
}; };
let child: ChildProcess;
try { try {
child = await spawnDaemon(); await spawnDaemon();
} catch (error) { } catch (error) {
await stopDaemon();
if (!options.preserveHome) { if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
} }
@@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon(
serverId, serverId,
port, port,
paseoHome, paseoHome,
getPid: () => child.pid, getPid: () => child?.pid,
restart: async () => { restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await killProcessTree(child); await stopDaemon();
child = await spawnDaemon(); await spawnDaemon();
}, },
close: async () => { close: async () => {
if (closed) return; if (closed) return;
closed = true; await stopDaemon();
await killProcessTree(child);
if (!options.preserveHome) { if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
} }
if (publishedPackageRoot) { if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true }); await rm(publishedPackageRoot, { recursive: true, force: true });
} }
closed = true;
}, },
}; };
} }
@@ -28,6 +28,10 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly. * prefer those wrappers over reaching for this client directly.
*/ */
export interface SeedDaemonClient { 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>; connect(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
addProject(cwd: string): Promise<{ addProject(cwd: string): Promise<{
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
} }
export async function expectDirectHostSslEnabled(page: Page): Promise<void> { export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible(); await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
} }
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
}
export async function toggleDirectHostSsl(page: Page): Promise<void> {
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
}
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> { export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri); await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
} }
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number { export function round2(value: number): number {
return Math.round(value * 100) / 100; 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,6 +25,7 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it. * app connects, so a reload can switch it.
*/ */
usageSupported?: boolean | (() => boolean); usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */ /** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean; providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */ /** The host daemon's port; defaults to the E2E daemon. */
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
message: WebSocketMessage, message: WebSocketMessage,
enabled: boolean, enabled: boolean,
providerUsageListOnly: boolean, providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null { ): string | null {
const envelope = parseJson(message) as { const envelope = parseJson(message) as {
type?: unknown; type?: unknown;
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
...payload, ...payload,
features: { features: {
...features, ...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly, usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled, providerUsageList: enabled,
}, },
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
server.onMessage((message) => { server.onMessage((message) => {
const serverInfo = const serverInfo =
typeof message === "string" typeof message === "string"
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) ? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
: null; : null;
ws.send(serverInfo ?? message); ws.send(serverInfo ?? message);
}); });
@@ -0,0 +1,25 @@
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"
}
@@ -0,0 +1,38 @@
[
{
"output": "स्ते",
"row": "4,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": "3,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": "1,1,3,1,3,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]
},
{
"output": "अ्क",
"row": "3,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]
}
]
@@ -0,0 +1,132 @@
package sh.paseo.terminal
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")
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
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,24 @@
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() }
}
}
}
@@ -0,0 +1,195 @@
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
}
}
@@ -0,0 +1,76 @@
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())
}
}
@@ -0,0 +1,61 @@
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
}
}
}
@@ -0,0 +1,99 @@
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
}
}
@@ -0,0 +1,302 @@
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
}
@@ -0,0 +1,59 @@
/**
* 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,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph"; import { encodeTerminalCustomGlyphs, resolveTerminalCustomGlyph } from "./custom-glyphs";
describe("native terminal custom glyphs", () => { describe("native terminal custom glyphs", () => {
it("fills the complete cell for the full block used by the Claude mascot", () => { it("fills the complete cell for the full block used by the Claude mascot", () => {
@@ -85,4 +85,22 @@ describe("native terminal custom glyphs", () => {
expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull(); expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull();
} }
}); });
it("encodes every glyph for the native renderer in cell fractions", () => {
const encoded = new Map(encodeTerminalCustomGlyphs().map((glyph) => [glyph.char, glyph]));
expect(encoded.get("▛")).toEqual({
char: "▛",
rects: [0, 0, 0.5, 0.5, 0.5, 0, 0.5, 0.5, 0, 0.5, 0.5, 0.5],
path: [],
strokeWidth: 0,
});
expect(encoded.get("╭")).toEqual({
char: "╭",
rects: [],
path: [0, 0.5, 1, 1, 0.5, 0.75, 2, 0.5, 0.5, 0.75, 0.5, 1, 1, 0.5],
strokeWidth: 1,
});
expect(encoded.get("┣")?.strokeWidth).toBe(3);
});
}); });
@@ -82,12 +82,60 @@ const BLOCK_GLYPHS: Readonly<Record<string, TerminalRectGlyph>> = {
"▟": rects(upperRight(), lowerLeft(), lowerRight()), "▟": rects(upperRight(), lowerLeft(), lowerRight()),
}; };
/**
* One custom glyph as the native view consumes it. Coordinates are fractions of one cell.
* `rects` is flattened `[x, y, width, height]`. `path` is flattened commands:
* `0 x y` moves, `1 x y` draws a line, `2 cx cy x y` draws a quadratic curve. Paths are
* stroked with `strokeWidth` points regardless of cell size.
*/
export interface PaseoTerminalCustomGlyph {
char: string;
rects: number[];
path: number[];
strokeWidth: number;
}
const PATH_COMMAND_CODES: Readonly<Record<string, { code: number; coordinates: number }>> = {
M: { code: 0, coordinates: 2 },
L: { code: 1, coordinates: 2 },
Q: { code: 2, coordinates: 4 },
};
export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null { export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null {
return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null; return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null;
} }
export function isTerminalCustomGlyph(char: string): boolean { export function encodeTerminalCustomGlyphs(): PaseoTerminalCustomGlyph[] {
return resolveTerminalCustomGlyph(char) !== null; const table = { ...LIGHT_BOX_GLYPHS, ...HEAVY_BOX_GLYPHS, ...BLOCK_GLYPHS };
return Object.entries(table).map(([char, glyph]) =>
glyph.kind === "rects"
? {
char,
rects: glyph.rects.flatMap((value) => [value.x, value.y, value.width, value.height]),
path: [],
strokeWidth: 0,
}
: { char, rects: [], path: encodePath(glyph.path), strokeWidth: glyph.strokeWidth },
);
}
function encodePath(pathData: string): number[] {
const tokens = pathData.match(/[MLQ]|-?\d*\.?\d+/g) ?? [];
const encoded: number[] = [];
let index = 0;
while (index < tokens.length) {
const command = PATH_COMMAND_CODES[tokens[index]];
if (!command) {
throw new Error(`Unsupported terminal glyph path command in ${pathData}`);
}
const coordinates = tokens.slice(index + 1, index + 1 + command.coordinates).map(Number);
if (coordinates.length !== command.coordinates || coordinates.some(Number.isNaN)) {
throw new Error(`Malformed terminal glyph path ${pathData}`);
}
encoded.push(command.code, ...coordinates);
index += 1 + command.coordinates;
}
return encoded;
} }
function path(pathData: string, strokeWidth = 1): TerminalPathGlyph { function path(pathData: string, strokeWidth = 1): TerminalPathGlyph {
@@ -0,0 +1,9 @@
{
"platforms": ["android", "ios"],
"android": {
"modules": ["sh.paseo.terminal.PaseoTerminalModule"]
},
"ios": {
"modules": ["PaseoTerminalModule"]
}
}
@@ -0,0 +1,34 @@
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"]);
});
});
@@ -0,0 +1,27 @@
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;
}
@@ -0,0 +1,47 @@
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}
/>
);
}
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,21 @@
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() }
}
}
}
@@ -0,0 +1,177 @@
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
}
}
@@ -0,0 +1,73 @@
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] }
}
@@ -0,0 +1,67 @@
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
}
}
@@ -0,0 +1,121 @@
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)
}
}
@@ -0,0 +1,336 @@
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))
}
}
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-terminal",
"version": "0.1.0",
"private": true
}
+7 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.14.0", "version": "0.15.0-beta.1",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -71,13 +71,14 @@
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213", "@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213", "@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213", "@xterm/addon-image": "0.10.0-beta.331",
"@xterm/addon-ligatures": "^0.11.0-beta.213", "@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213", "@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291", "@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.292", "@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"expo": "~57.0.30", "expo": "~57.0.30",
"expo-asset": "~57.0.20", "expo-asset": "~57.0.20",
@@ -154,7 +155,8 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7", "@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7", "@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213", "@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16", "babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
@@ -1,8 +1,16 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import equal from "fast-deep-equal";
import { normalizeWorkspacePath } from "@/utils/workspace-identity"; import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands"; export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
export const sessionCommandQueryPolicy = {
staleTime: 600_000,
gcTime: 1_800_000,
refetchOnMount: true,
refetchOnWindowFocus: false,
} as const;
export interface AgentCommandsDraftConfig { export interface AgentCommandsDraftConfig {
provider: AgentProvider; provider: AgentProvider;
@@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) {
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const; return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
} }
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
export function invalidateCommandsForProviderChange(input: {
queryClient: QueryClient;
serverId: string;
cwd: string | null;
previous: readonly ProviderSnapshotEntry[] | undefined;
current: readonly ProviderSnapshotEntry[];
}): Promise<void> {
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
entry && {
status: entry.status,
enabled: entry.enabled,
source: entry.source,
models: entry.models,
modes: entry.modes,
defaultModeId: entry.defaultModeId,
};
return input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
predicate: (query) => {
const isDraft = query.queryKey[2] === "draft";
const provider =
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
return false;
if (typeof provider !== "string") return false;
const before = previous.get(provider);
const after = current.get(provider);
if (!before) return after?.status === "ready" && query.state.status === "error";
if (before.status !== "ready")
return after?.status === "ready" && query.state.status === "error";
return !equal(relevant(before), relevant(after));
},
});
}
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) { export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
} }
+99
View File
@@ -0,0 +1,99 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ComposerEditingSession } from "@/composer/editing-session";
import { agentCommandsQueryKey } from "./cache";
import {
useAgentCommandsQuery,
type AgentSlashCommand,
type DraftCommandConfig,
} from "./use-query";
export type {
AgentSlashCommand,
DraftCommandConfig,
DraftCommandTarget,
AgentCommandsClient,
} from "./use-query";
export { fetchAgentCommands } from "./use-query";
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
const QueryContext = createContext<CommandQuery | null>(null);
export interface CommandHints {
names: ReadonlySet<string>;
insert(command: string): void;
}
const HintsContext = createContext<CommandHints | null>(null);
function useLiveAgentCommands() {
return useContext(QueryContext);
}
export function useAutocompleteCommands(input: {
serverId: string;
agentId: string;
enabled: boolean;
draftConfig?: DraftCommandConfig;
}) {
const live = useLiveAgentCommands();
const fallback = useAgentCommandsQuery({
serverId: input.serverId,
agentId: input.agentId,
enabled: !live && input.enabled,
draftConfig: input.draftConfig,
});
return live ?? fallback;
}
export function useAgentCommandHints() {
return useContext(HintsContext);
}
export function matchCommandLink(
url: string,
sourceType: "markdown" | "text" | "inline-code" | undefined,
names: ReadonlySet<string>,
): string | null {
return (sourceType === "markdown" || sourceType === "inline-code") &&
url.startsWith("/") &&
names.has(url.slice(1))
? url
: null;
}
export function AgentCommandsProvider({
serverId,
agentId,
editingSession,
enabled,
children,
}: {
serverId: string;
agentId: string;
editingSession: ComposerEditingSession;
enabled: boolean;
children: ReactNode;
}) {
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
const queryClient = useQueryClient();
const namesKey = JSON.stringify(
[...new Set(query.commands.map((command) => command.name))].sort(),
);
const names = useMemo<ReadonlySet<string>>(
() => new Set(JSON.parse(namesKey) as string[]),
[namesKey],
);
const insert = useCallback(
(token: string) => {
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
agentCommandsQueryKey({ serverId, agentId }),
);
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
editingSession.insertCommand(token);
}
},
[agentId, editingSession, queryClient, serverId],
);
const hints = useMemo(() => ({ names, insert }), [names, insert]);
return (
<QueryContext.Provider value={enabled ? query : null}>
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
</QueryContext.Provider>
);
}
@@ -1,11 +1,61 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import { import {
agentCommandsQueryKey, agentCommandsQueryKey,
agentCommandsQueryRoot, agentCommandsQueryRoot,
draftAgentCommandsQueryKey, draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd, normalizeAgentCommandsCwd,
sessionAgentCommandsQueryKey, sessionAgentCommandsQueryKey,
} from "./agent-commands-query"; sessionCommandQueryPolicy,
} from "./cache";
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
let calls = 0;
let fail = false;
let resolveRefresh: (value: string[]) => void = () => {};
const refresh = new Promise<string[]>((resolve) => {
resolveRefresh = resolve;
});
const options = fetchQueryOptions({
...sessionCommandQueryPolicy,
staleTimeMs: sessionCommandQueryPolicy.staleTime,
dataShape: "value",
queryKey: key,
retry: false,
queryFn: async () => {
calls++;
if (fail) throw new Error("offline");
return refresh;
},
});
client.setQueryData(key, ["old"]);
const fresh = new QueryObserver(client, options);
const unsubscribeFresh = fresh.subscribe(() => {});
expect(calls).toBe(0);
unsubscribeFresh();
client.setQueryData(key, ["old"], { updatedAt: 1 });
const first = new QueryObserver(client, options);
const second = new QueryObserver(client, options);
const unsubscribeFirst = first.subscribe(() => {});
const unsubscribeSecond = second.subscribe(() => {});
expect(calls).toBe(1);
expect(first.getCurrentResult().data).toEqual(["old"]);
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
resolveRefresh(["new"]);
await refresh;
await first.refetch({ cancelRefetch: false });
expect(second.getCurrentResult().data).toEqual(["new"]);
fail = true;
await client.invalidateQueries({ queryKey: key });
expect(first.getCurrentResult().data).toEqual(["new"]);
expect(first.getCurrentResult().isError).toBe(true);
unsubscribeFirst();
unsubscribeSecond();
client.clear();
});
describe("agent command query keys", () => { describe("agent command query keys", () => {
it("shares draft commands across draft tabs with the same provider setup", () => { it("shares draft commands across draft tabs with the same provider setup", () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
type ListCommands = AgentCommandsClient["listCommands"]; type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>; type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
@@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
} }
describe("fetchAgentCommands", () => { describe("fetchAgentCommands", () => {
it("rejects a discovery error so a previous catalog is retained", async () => {
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
"provider unavailable",
);
});
it("loads commands for a draft composer without an agent id", async () => { it("loads commands for a draft composer without an agent id", async () => {
const client = createClient( const client = createClient(
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
@@ -1,12 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/shallow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; import {
agentCommandsQueryKey,
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY; sessionCommandQueryPolicy,
const SESSION_COMMANDS_STALE_TIME = 60_000; type AgentCommandsDraftConfig,
} from "./cache";
export interface AgentSlashCommand { export interface AgentSlashCommand {
name: string; name: string;
@@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: {
agentId: input.agentId, agentId: input.agentId,
draftConfig: input.draftConfig, draftConfig: input.draftConfig,
}); });
if (response.error) throw new Error(response.error);
return response.commands as AgentSlashCommand[]; return response.commands as AgentSlashCommand[];
} }
@@ -62,8 +66,16 @@ export function useAgentCommandsQuery({
const queryEnabled = enabled && retainedPanelActive; const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const { provider, cwd } = useSessionStore(
useShallow((state) => {
const agent =
state.sessions[serverId]?.agents?.get(agentId) ??
state.sessions[serverId]?.agentDetails?.get(agentId);
return { provider: agent?.provider, cwd: agent?.cwd };
}),
);
const query = useQuery({ const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client) {
@@ -72,7 +84,15 @@ export function useAgentCommandsQuery({
return fetchAgentCommands({ client, agentId, draftConfig }); return fetchAgentCommands({ client, agentId, draftConfig });
}, },
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig), enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, dataShape: "value",
meta: {
agentCommandProvider: draftConfig?.provider ?? provider,
agentCommandCwd: draftConfig?.cwd ?? cwd,
},
...(draftConfig
? { immutableWhen: () => true as const }
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
subscribed: queryEnabled,
retry: 3, retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
}); });
@@ -84,7 +104,7 @@ export function useAgentCommandsQuery({
return { return {
commands: query.data ?? [], commands: query.data ?? [],
isLoading, isLoading,
isError: query.isError, isError: query.isError && query.data === undefined,
error: query.error, error: query.error,
}; };
} }
+1
View File
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() => () =>
createInlinePathOpener({ createInlinePathOpener({
workspaceRoot, workspaceRoot,
resolveRelativeFiles: true,
client, client,
openFile: openInlineFile, openFile: openInlineFile,
openDirectory: openInlineDirectory, openDirectory: openInlineDirectory,
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
const opened: string[] = []; const opened: string[] = [];
const opener = createInlinePathOpener({ const opener = createInlinePathOpener({
workspaceRoot: "/repo", workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe }, client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => { openFile: (location) => {
opened.push(location.path); opened.push(location.path);
}, },
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
const opened: string[] = []; const opened: string[] = [];
const opener = createInlinePathOpener({ const opener = createInlinePathOpener({
workspaceRoot: "/repo", workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe }, client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => { openFile: (location) => {
opened.push(location.path); opened.push(location.path);
}, },
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]); expect(workspaceFolders).toEqual(["docs"]);
}); });
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
const { createInlinePathOpener } = await import("./path-open");
const lookup = Promise.withResolvers<{
entries: Array<{ path: string; kind: "file" }>;
error: null;
}>();
const queries: string[] = [];
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
resolveRelativeFiles: true,
client: {
listDirectory: async () => {
throw new Error("Not a directory");
},
getDirectorySuggestions: async ({ query }: { query: string }) => {
queries.push(query);
return lookup.promise;
},
},
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const earlier = opener.open(
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
"side",
);
await Promise.resolve();
expect(queries).toEqual(["schema/x.md"]);
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
await earlier;
expect(opened).toEqual(["guide.md"]);
});
@@ -5,6 +5,7 @@ import {
type OpenFileDisposition, type OpenFileDisposition,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
interface PathOpenInput { interface PathOpenInput {
file?: string; file?: string;
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
export function createInlinePathOpener(input: { export function createInlinePathOpener(input: {
workspaceRoot: string; workspaceRoot: string;
client: PathOpenInput["client"]; client: {
listDirectory(root: string, path: string): Promise<unknown>;
getDirectorySuggestions: GetDirectorySuggestions;
} | null;
resolveRelativeFiles?: boolean;
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void; openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void; openOutsideDirectory: (path: string) => void;
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
} else if (destination.kind === "outsideDirectory") { } else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath); input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) { } else if (normalized.file) {
const resolvedTarget =
input.resolveRelativeFiles && input.client
? await resolveMissingNestedRelativeFile({
target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
})
: target;
if (ownGeneration !== generation) return;
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
if (!resolved?.file) return;
const location = normalizeWorkspaceFileLocation({ const location = normalizeWorkspaceFileLocation({
path: normalized.file, path: resolved.file,
lineStart: target.lineStart, lineStart: resolvedTarget.lineStart,
lineEnd: target.lineEnd, lineEnd: resolvedTarget.lineEnd,
}); });
if (location) input.openFile(location, disposition); if (location) input.openFile(location, disposition);
} }
@@ -7,6 +7,7 @@ import {
classifyForResolution, classifyForResolution,
fetchDaemonResolution, fetchDaemonResolution,
getAssistantFileLinkToken, getAssistantFileLinkToken,
resolveMissingNestedRelativeFile,
UnresolvedFileLinkError, UnresolvedFileLinkError,
type AssistantFileLinkContext, type AssistantFileLinkContext,
type DirectorySuggestionEntry, type DirectorySuggestionEntry,
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
}); });
}); });
describe("resolveMissingNestedRelativeFile", () => {
const target = {
raw: "schema/x.md:12-14",
path: "/Users/test/project/schema/x.md",
lineStart: 12,
lineEnd: 14,
};
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "/Users/test/project",
matchMode: "suffix",
limit: 1,
},
]);
expect(result).toEqual({
...target,
path: "/Users/test/project/repo-a/schema/x.md",
});
});
it("keeps the exact workspace path when suggestions return that relative path", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(result).toEqual(target);
});
it("keeps the original target when suggestions are empty", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(target);
});
it("keeps the original target when the daemon throws", async () => {
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions: unavailableSuggestions,
}),
).resolves.toEqual(target);
});
it.each([
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
["a home-relative path", { ...target, path: "~/schema/x.md" }],
])("does not search %s", async (_label, candidate) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: candidate,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(candidate);
});
it("does not search when the workspace root is blank", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: " ",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(target);
});
it.each([
["/Users/test/project/schema/x.md", "/Users/test/project"],
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
["C:/repo/schema/x.md", "C:/repo"],
["\\\\server\\share\\schema\\x.md", "//server/share"],
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
await expect(
resolveMissingNestedRelativeFile({
target: absoluteTarget,
workspaceRoot,
getDirectorySuggestions,
}),
).resolves.toEqual(absoluteTarget);
expect(searches).toEqual([]);
});
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
"keeps folder href %s direct",
async (raw) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
});
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
await expect(
resolveMissingNestedRelativeFile({
target: folderTarget,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(folderTarget);
expect(searches).toEqual([]);
},
);
it("normalizes a Windows workspace root before the suffix lookup", async () => {
const windowsTarget = {
raw: "schema/x.md",
path: "C:\\repo\\schema\\x.md",
lineStart: undefined,
lineEnd: undefined,
};
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: windowsTarget,
workspaceRoot: "C:\\repo\\",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "C:\\repo\\",
matchMode: "suffix",
limit: 1,
},
]);
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
});
});
describe("getAssistantFileLinkToken", () => { describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect( expect(
@@ -5,6 +5,7 @@ import {
type InlinePathTarget, type InlinePathTarget,
} from "./parse"; } from "./parse";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export interface AssistantFileLinkSource { export interface AssistantFileLinkSource {
href: string; href: string;
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
}; };
} }
/** Only file-looking paths nested inside the workspace need suffix search. */
function nestedRelativeLookupQuery(
targetPath: string,
workspaceRoot: string | undefined,
): string | null {
const root = workspaceRoot?.trim();
if (!root) {
return null;
}
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = targetPath.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const relative = normalizedPath.slice(prefix.length);
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
return null;
}
return relative;
}
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
* missing, ask the same suffix lookup bare names already use. An exact hit
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
* Lookup failure keeps the original target.
*/
export async function resolveMissingNestedRelativeFile(input: {
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}): Promise<InlinePathTarget> {
if (
isAbsoluteInlineCodeToken(input.target.raw) ||
input.target.raw.startsWith("~/") ||
input.target.raw.endsWith("/")
) {
return input.target;
}
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
if (!query) {
return input.target;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: query,
token: input.target.raw,
target: input.target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.getDirectorySuggestions,
});
} catch {
return input.target;
}
}
export function classifyForResolution( export function classifyForResolution(
source: AssistantFileLinkSource, source: AssistantFileLinkSource,
context: AssistantFileLinkContext, context: AssistantFileLinkContext,
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
} }
function isAbsoluteInlineCodeToken(token: string): boolean { function isAbsoluteInlineCodeToken(token: string): boolean {
return ( return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
} }
function isLinkifiedSource(source: AssistantFileLinkSource): boolean { function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
+5 -33
View File
@@ -6,15 +6,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy"; import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials"; import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -139,37 +136,11 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection( function prepareDirectConnection(
draft: DirectConnectionDraft, draft: DirectConnectionDraft,
labels: DirectConnectionLabels, labels: DirectConnectionLabels,
): PreparedDirectConnection { ): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels)); const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
const endpoint = parsed.isIpv6 const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}` ? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`; : `${parsed.host}:${parsed.port}`;
@@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
advancedTarget.current = new PairingTargetTracker("", true); advancedTarget.current = new PairingTargetTracker("", true);
try { try {
setAdvancedUri( setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels), buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
); );
} catch { } catch {
setAdvancedUri(""); setAdvancedUri("");
@@ -505,6 +476,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityLabel={t("pairing.direct.fields.useSsl")} accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState} accessibilityState={useTlsAccessibilityState}
aria-checked={useTls}
testID="direct-ssl-toggle" testID="direct-ssl-toggle"
> >
<View style={checkboxStyle}> <View style={checkboxStyle}>
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
};
function buildUri(host: string, port = "6767"): string {
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
}
describe("normalizeDirectConnectionHost", () => {
it.each([
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
["[::1]", { host: "::1", port: null, isIpv6: true }],
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
["::1", { host: "::1", port: null, isIpv6: true }],
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
["", { host: "", port: null, isIpv6: false }],
])("normalizes %j", (input, expected) => {
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
});
});
describe("buildDirectConnectionUri", () => {
it.each([
["100.66.69.36", "tcp://100.66.69.36:6767"],
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
["my-host.local:6767", "tcp://my-host.local:6767"],
["[::1]", "tcp://[::1]:6767"],
["[::1]:7000", "tcp://[::1]:7000"],
["::1", "tcp://[::1]:6767"],
["fe80::1", "tcp://[fe80::1]:6767"],
])("builds a tcp URI from host %j", (host, expected) => {
expect(buildUri(host)).toBe(expected);
});
it("lets a port typed in the host field override the port field", () => {
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
});
it("keeps TLS and password settings", () => {
expect(
buildDirectConnectionUri(
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
labels,
),
).toBe("tcp://example.test:443?ssl=true&password=secret");
});
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
expect(() => buildUri(host)).toThrow("host required");
});
it("rejects an invalid port", () => {
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
});
});
@@ -0,0 +1,70 @@
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
export interface DirectConnectionHostInput {
host: string;
port: string;
useTls: boolean;
password: string;
}
export interface DirectConnectionHostLabels {
hostRequired: string;
invalidPort: string;
}
export interface NormalizedDirectConnectionHost {
host: string;
/** Port typed into the host field, if any. Overrides the separate port field. */
port: string | null;
isIpv6: boolean;
}
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
/**
* Users often paste `host:port` or a full URL into the host field.
* Reduce it to a bare host (plus an optional port) before building the URI.
*/
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
let host = input.trim().replace(SCHEME_PATTERN, "");
const suffixIndex = host.search(/[/?#]/);
if (suffixIndex !== -1) {
host = host.slice(0, suffixIndex);
}
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
if (bracketed) {
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
}
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
if (hostWithPort) {
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
}
return { host, port: null, isIpv6: host.split(":").length > 2 };
}
export function buildDirectConnectionUri(
draft: DirectConnectionHostInput,
labels: DirectConnectionHostLabels,
): string {
const normalized = normalizeDirectConnectionHost(draft.host);
const port = Number((normalized.port ?? draft.port).trim());
if (!normalized.host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host: normalized.host,
port,
isIpv6: normalized.isIpv6,
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
+4 -9
View File
@@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation"; import { openHostOverview } from "@/navigation/settings-navigation";
import { import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/ */
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer"); const { items } = useSidebarNavItems("footer");
const hasUsageSummary = useHasUsageSummary(); const usageSummary = useUsageSummary();
const rowsRef = useRef<View | null>(null); const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter( const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary), (item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
); );
if (visibleItems.length === 0) return null; if (visibleItems.length === 0) return null;
return ( return (
@@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate} onBeforeNavigate={onBeforeNavigate}
/> />
) : ( ) : (
<UsageSidebarItem key={item.key} /> <UsageSidebarItem key={item.key} sources={usageSummary} />
), ),
)} )}
</View> </View>
@@ -35,11 +35,15 @@ export interface MarkdownImageContext {
filePath?: string; filePath?: string;
} }
export function useMarkdownSource(text: string, context: MarkdownImageContext) { export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
) {
const occurrenceKey = useId(); const occurrenceKey = useId();
const prepared = useMemo( const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot], [text, context.workspaceRoot, commandNames],
); );
const sources = [...new Set(prepared.images.map((image) => image.source))]; const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => { const acquisitions = sources.map((source) => {
@@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { classifyAssistantFileLink } from "@/assistant-file-links"; import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
@@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles";
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
import { normalizeMarkdownHtml } from "./html"; import { normalizeMarkdownHtml } from "./html";
it("recognizes complete known commands in inline code", () => {
const names = new Set(["code-review", "review:fast"]);
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
commandNames: names,
});
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
);
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
});
it("resolves authored root-relative links only when the target is a known command", () => {
const names = new Set(["code-review"]);
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
});
describe("shared Markdown source adaptation", () => { describe("shared Markdown source adaptation", () => {
it("keeps paragraphs, fences and quotes together for the library", () => { it("keeps paragraphs, fences and quotes together for the library", () => {
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
@@ -6,6 +6,7 @@ import {
useAssistantFileLinkEnvironment, useAssistantFileLinkEnvironment,
} from "@/assistant-file-links"; } from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
selectable?: boolean; selectable?: boolean;
streaming?: boolean; streaming?: boolean;
onLinkPress?: (url: string) => unknown; onLinkPress?: (url: string) => unknown;
agentCommands?: boolean;
} }
/** All Markdown surfaces share the same engine and source adaptation. */ /** All Markdown surfaces share the same engine and source adaptation. */
@@ -59,15 +61,21 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
() => splitStreamingMarkdown(completeLines, true).head.length, () => splitStreamingMarkdown(completeLines, true).head.length,
[completeLines], [completeLines],
); );
if (!streaming || boundary === 0) return <MarkdownPart {...props} />; const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
if (!streaming || boundary === 0) return <Part {...props} />;
return ( return (
<MarkdownBlockStack> <MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} /> <Part {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} /> <Part {...props} text={props.text.slice(boundary)} />
</MarkdownBlockStack> </MarkdownBlockStack>
); );
}); });
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
const commandHints = useAgentCommandHints();
return <MarkdownPart {...props} commandHints={commandHints} />;
});
const MarkdownPart = memo(function MarkdownPart({ const MarkdownPart = memo(function MarkdownPart({
text, text,
compact = false, compact = false,
@@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({
selectable = true, selectable = true,
streaming = false, streaming = false,
onLinkPress, onLinkPress,
agentCommands: _agentCommands,
commandHints = null,
...imageContext ...imageContext
}: MarkdownRendererProps) { }: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment(); const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions(); const actions = useAssistantFileLinkActions();
const client = const client =
@@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({
(environment.client?.readFile (environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) } ? { readFile: environment.client.readFile.bind(environment.client) }
: undefined); : undefined);
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
workspaceRoot: environment.workspaceRoot, text,
serverId: environment.serverId, {
...imageContext, workspaceRoot: environment.workspaceRoot,
client, serverId: environment.serverId,
}); ...imageContext,
client,
},
commandHints?.names,
);
const [image, setImage] = useState<ImageLightboxSource | null>(null); const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
if (command) {
commandHints.insert(command);
return;
}
const source = linkSource(url, sourceType); const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred"); else actions.open(source, "preferred");
+14 -4
View File
@@ -56,7 +56,11 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
}; };
} }
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> { function collectInlineCodePaths(
source: string,
workspaceRoot?: string,
commandNames?: ReadonlySet<string>,
): ReadonlySet<string> {
const paths = new Set<string>(); const paths = new Set<string>();
const previousEnds = new Map<number, number>(); const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns // Collect candidates between consecutive runs of equal length. The engine owns
@@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl
value = value.slice(1, -1); value = value.slice(1, -1);
} }
const classification = classifyAssistantFileLink(value, { workspaceRoot }); const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value); if (
(classification && classification.kind !== "external") ||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
)
paths.add(value);
} }
return paths; return paths;
} }
@@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource( export function prepareMarkdownSource(
source: string, source: string,
workspaceRoot?: string, workspaceRoot?: string,
options: { supportsSvg?: boolean } = {}, options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
) { ) {
source = normalizeMarkdownHtml(source); source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = []; const images: MarkdownImage[] = [];
@@ -122,7 +130,9 @@ export function prepareMarkdownSource(
extensions: [gfmTable()], extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()], mdastExtensions: [gfmTableFromMarkdown()],
})); }));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot)); const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
);
const edits: { start: number; end: number; text: string }[] = []; const edits: { start: number; end: number; text: string }[] = [];
if ( if (
!source.includes("![") && !source.includes("![") &&
+1
View File
@@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({
> >
<MarkdownRenderer <MarkdownRenderer
text={revealedMessage} text={revealedMessage}
agentCommands
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length} streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
client={client} client={client}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done")); const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items.slice(0, 7)); expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7); expect(result.current.canToggle).toBe(count > 7);
}); });
@@ -471,7 +471,9 @@ describe("sidebar group visibility", () => {
"keeps row twelve visible for %s without filling gaps", "keeps row twelve visible for %s without filling gaps",
(status) => { (status) => {
const items = entries([...Array(11).fill("done"), status]); const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, policy }),
);
expect(names(result.current.visibleItems)).toEqual([ expect(names(result.current.visibleItems)).toEqual([
"row-1", "row-1",
"row-2", "row-2",
@@ -492,7 +494,11 @@ describe("sidebar group visibility", () => {
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) { for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() => const { result } = renderHook(() =>
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)), useLimitedSidebarGroup({
items,
selection: null,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
); );
expect(result.current.canToggle).toBe(true); expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual( expect(names(result.current.visibleItems)).toEqual(
@@ -512,7 +518,7 @@ describe("sidebar group visibility", () => {
"needs_input", "needs_input",
"failed", "failed",
]); ]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items); expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false); expect(result.current.canToggle).toBe(false);
}); });
@@ -527,7 +533,7 @@ describe("sidebar group visibility", () => {
"attention", "attention",
]); ]);
const { result, rerender, unmount } = renderHook( const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup(rows, policy), ({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } }, { initialProps: { rows: items } },
); );
expect(names(result.current.visibleItems)).toEqual([ expect(names(result.current.visibleItems)).toEqual([
@@ -568,7 +574,7 @@ describe("sidebar group visibility", () => {
expect(result.current.expanded).toBe(false); expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded()); act(() => result.current.toggleExpanded());
unmount(); unmount();
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(remounted.result.current.expanded).toBe(false); expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10); expect(remounted.result.current.visibleItems).toHaveLength(10);
}); });
@@ -592,7 +598,12 @@ describe("sidebar group visibility", () => {
lifecycle: "active" as const, lifecycle: "active" as const,
}; };
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), ({ last }) =>
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
policy,
}),
{ initialProps: { last: items[11] } }, { initialProps: { last: items[11] } },
); );
expect(result.current.visibleItems).toHaveLength(7); expect(result.current.visibleItems).toHaveLength(7);
@@ -630,7 +641,7 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => { it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running")); const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup(items)); const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
expect(result.current.visibleItems).toEqual(items.slice(0, 20)); expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true); expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded()); act(() => result.current.toggleExpanded());
@@ -1598,11 +1598,16 @@ function ProjectBlock({
[workspaceEntriesByKey], [workspaceEntriesByKey],
); );
const { const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces, visibleItems: visibleWorkspaces,
expanded: workspacesExpanded, expanded: workspacesExpanded,
canToggle: canToggleWorkspaces, canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded, toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); } = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null; const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1761,28 +1766,33 @@ function ProjectBlock({
let projectChildren = null; let projectChildren = null;
if (!collapsed) { if (!collapsed) {
if (project.workspaces.length > 0) { if (project.workspaces.length > 0) {
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
projectChildren = ( projectChildren = (
<> <>
{manualSort ? ( {manualSort ? (
<DraggableList <>
testID={`sidebar-workspace-list-${project.viewKey}`} {leadingWorkspaceRow}
data={visibleWorkspaces} <DraggableList
keyExtractor={workspaceKeyExtractor} testID={`sidebar-workspace-list-${project.viewKey}`}
renderItem={renderWorkspace} data={visibleWorkspaces}
onDragEnd={handleWorkspaceDragEnd} keyExtractor={workspaceKeyExtractor}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)} renderItem={renderWorkspace}
scrollEnabled={false} onDragEnd={handleWorkspaceDragEnd}
useDragHandle extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
nestable={useNestable} scrollEnabled={false}
simultaneousGestureRef={parentGestureRef} useDragHandle
gestureHostPresented={dragGestureHostActive} nestable={useNestable}
containerStyle={styles.workspaceListContainer} simultaneousGestureRef={parentGestureRef}
/> gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
</>
) : ( ) : (
<View <View
testID={`sidebar-workspace-list-${project.viewKey}`} testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer} style={styles.workspaceListContainer}
> >
{leadingWorkspaceRow}
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))} {visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View> </View>
)} )}
@@ -2188,11 +2198,15 @@ function ProjectModeList({
const activeWorkspaceSelection = useActiveWorkspaceSelection(); const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups; const { pinnedChats, unpinnedProjects } = pinnedGroups;
const { const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats, visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded, expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats, canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded, toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats); } = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
});
const nativeScrollGestureProps = useMemo( const nativeScrollGestureProps = useMemo(
() => () =>
parentGestureRef parentGestureRef
@@ -2395,37 +2409,38 @@ function ProjectModeList({
[renderProjectBlock], [renderProjectBlock],
); );
const renderPinnedChat = useCallback( const renderPinnedChatRow = useCallback(
({ (
item: workspace, workspace: SidebarWorkspacePlacement,
drag, input?: {
isActive, drag?: () => void;
dragHandleProps, isDragging?: boolean;
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => { dragHandleProps?: DraggableListDragHandleProps;
return ( },
<MemoWorkspaceRowItem ) => (
workspace={workspace} <MemoWorkspaceRowItem
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null} key={workspace.workspaceKey}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null} workspace={workspace}
leadingProjectName={workspace.projectName} workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
leadingProjectIconDataUri={ hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null leadingProjectName={workspace.projectName}
} leadingProjectIconDataUri={
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null} projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
showShortcutBadge={showShortcutBadges} }
canCopyBranchName={workspace.projectKind === "git"} shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
canPin={supportsPinningByServerId.get(workspace.serverId) === true} showShortcutBadge={showShortcutBadges}
onToggleWorkspacePin={onToggleWorkspacePin} canCopyBranchName={workspace.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)} canPin={supportsPinningByServerId.get(workspace.serverId) === true}
selectionEnabled={selectionEnabled} onToggleWorkspacePin={onToggleWorkspacePin}
activeWorkspaceSelection={activeWorkspaceSelection} isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
onWorkspacePress={onWorkspacePress} selectionEnabled={selectionEnabled}
drag={drag} activeWorkspaceSelection={activeWorkspaceSelection}
isDragging={isActive} onWorkspacePress={onWorkspacePress}
dragHandleProps={dragHandleProps} drag={input?.drag}
/> isDragging={input?.isDragging}
); dragHandleProps={input?.dragHandleProps}
}, />
),
[ [
activeWorkspaceSelection, activeWorkspaceSelection,
creatingWorkspaceIds, creatingWorkspaceIds,
@@ -2441,6 +2456,22 @@ function ProjectModeList({
], ],
); );
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderPinnedChatRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
});
},
[renderPinnedChatRow],
);
const projectBody = const projectBody =
projects.length === 0 ? ( projects.length === 0 ? (
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} /> <SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
@@ -2473,6 +2504,7 @@ function ProjectModeList({
/> />
{pinnedCollapsed ? null : ( {pinnedCollapsed ? null : (
<> <>
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
<DraggableList <DraggableList
testID="sidebar-pinned-list" testID="sidebar-pinned-list"
data={visiblePinnedChats} data={visiblePinnedChats}
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
interface Item extends SidebarGroupItem {
id: number;
statusBucket: SidebarStateBucket;
}
const serverId = "host-a";
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
return statuses.map((statusBucket, index) => ({
id: index + 1,
serverId: rowServerId,
workspaceId: `workspace-${index + 1}`,
statusBucket,
}));
}
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
kind: "workspaces",
getStatusBucket: (item) => item.statusBucket,
};
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
return { serverId: item.serverId, workspaceId: item.workspaceId };
}
describe("selectSidebarGroupVisibility", () => {
it("leads with a hidden open row while keeping the baseline order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBe(items[8]);
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("leaves selected rows in place when they are already in the baseline", () => {
const items = rows([...Array(8).fill("done"), "running", "done"]);
const expected = [...items.slice(0, 7), items[8]];
for (const selectedIndex of [2, 8]) {
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[selectedIndex]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(expected);
expect(result.canToggle).toBe(true);
}
});
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[7]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(false);
});
it("keeps expanded groups in their original order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: true,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(true);
});
it("applies the twenty-row limit to pinned groups", () => {
const items = rows(Array(22).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: { kind: "pinned" },
selection: selectionFor(items[21]!),
});
expect(result.leadingItem).toBe(items[21]);
expect(result.visibleItems).toEqual(items.slice(0, 20));
expect(result.canToggle).toBe(true);
});
it("preserves the current limit when selection is null", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: null,
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("matches the open row by server and workspace identity", () => {
const items = rows(Array(9).fill("done"), "host-b");
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
});
@@ -1,4 +1,28 @@
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface SidebarGroupItem {
serverId: string;
workspaceId: string;
}
export interface SidebarGroupVisibilityInput<T> {
items: readonly T[];
expanded: boolean;
policy: SidebarGroupVisibilityPolicy<T>;
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
selection: ActiveWorkspaceSelection | null;
}
export interface SidebarGroupVisibility<T> {
/**
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
* out of group order, outside any drag list. Never also present in `visibleItems`.
*/
leadingItem: T | null;
visibleItems: T[];
canToggle: boolean;
}
export type SidebarGroupVisibilityPolicy<T> = export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" } | { kind: "pinned" }
@@ -16,18 +40,45 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
}; };
} }
export function selectSidebarGroupVisibility<T>( function isOpenWorkspace(
items: readonly T[], item: SidebarGroupItem,
expanded: boolean, selection: ActiveWorkspaceSelection | null,
policy: SidebarGroupVisibilityPolicy<T>, ): boolean {
) { return (
const limit = policy.kind === "pinned" ? 20 : 7; selection !== null &&
const baseline = items.filter( item.serverId === selection.serverId &&
(item, index) => item.workspaceId === selection.workspaceId
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
); );
}
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
input: SidebarGroupVisibilityInput<T>,
): SidebarGroupVisibility<T> {
const { items, expanded, policy, selection } = input;
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline: T[] = [];
let leadingItem: T | null = null;
let canToggle = false;
items.forEach((item, index) => {
const inBaseline =
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
if (inBaseline) {
baseline.push(item);
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
leadingItem = item;
} else {
canToggle = true;
}
});
if (expanded || !canToggle) {
return { leadingItem: null, visibleItems: items.slice(), canToggle };
}
return { return {
visibleItems: expanded ? items.slice() : baseline, leadingItem,
canToggle: baseline.length < items.length, visibleItems: baseline,
canToggle,
}; };
} }
@@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({
dragGestureHostActive, dragGestureHostActive,
}: StatusWorkspaceListProps) { }: StatusWorkspaceListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys, (state) => state.collapsedWorkspaceGroupKeys,
); );
@@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({
(state) => state.togglePinnedCollapsed, (state) => state.togglePinnedCollapsed,
); );
const { const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces, visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded, expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces, canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded, toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces); } = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
const statusShortcutIndex = showShortcutBadges const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey ? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX; : EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback( const renderPinnedWorkspaceRow = useCallback(
({ (
item: workspace, workspace: SidebarWorkspaceEntry,
drag, input?: {
isActive, drag?: () => void;
dragHandleProps, isDragging?: boolean;
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => ( dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<StatusWorkspaceRow <StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace} workspace={workspace}
{...buildStatusRowProjectPresentation({ {...buildStatusRowProjectPresentation({
workspace, workspace,
@@ -184,9 +189,9 @@ export function SidebarStatusWorkspaceList({
canPin={supportsPinningByServerId.get(workspace.serverId) === true} canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin} onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
drag={drag} drag={input?.drag}
isDragging={isActive} isDragging={input?.isDragging}
dragHandleProps={dragHandleProps} dragHandleProps={input?.dragHandleProps}
/> />
), ),
[ [
@@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({
supportsPinningByServerId, supportsPinningByServerId,
], ],
); );
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
renderPinnedWorkspaceRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
}),
[renderPinnedWorkspaceRow],
);
const content = ( const content = (
<> <>
{pinnedWorkspaces.length > 0 ? ( {pinnedWorkspaces.length > 0 ? (
@@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({
/> />
{pinnedCollapsed ? null : ( {pinnedCollapsed ? null : (
<> <>
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
<DraggableList <DraggableList
testID="sidebar-pinned-list" testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces} data={visiblePinnedWorkspaces}
@@ -345,12 +365,37 @@ function StatusGroupRows({
supportsPinningByServerId: ReadonlyMap<string, boolean>; supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin; onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) { }) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces, visibleItems: visibleWorkspaces,
expanded: workspacesExpanded, expanded: workspacesExpanded,
canToggle: canToggleWorkspaces, canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded, toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); } = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
policy: STATUS_GROUP_VISIBILITY,
});
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
return (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
);
}
return ( return (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}> <View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -360,22 +405,8 @@ function StatusGroupRows({
style={styles.statusWorkspaceListContainer} style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.key}`} testID={`sidebar-status-group-rows-${group.key}`}
> >
{visibleWorkspaces.map((workspace) => ( {leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
<StatusWorkspaceRow {visibleWorkspaces.map(renderWorkspaceRow)}
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
{canToggleWorkspaces ? ( {canToggleWorkspaces ? (
<SidebarGroupToggleRow <SidebarGroupToggleRow
expanded={workspacesExpanded} expanded={workspacesExpanded}
@@ -1,19 +1,32 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
selectSidebarGroupVisibility, selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibility,
type SidebarGroupVisibilityPolicy, type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility"; } from "./sidebar-group-visibility";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
const PINNED_POLICY = { kind: "pinned" } as const; const PINNED_POLICY = { kind: "pinned" } as const;
export function useLimitedSidebarGroup<T>( interface LimitedSidebarGroupInput<T> {
items: readonly T[], items: readonly T[];
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY, selection: ActiveWorkspaceSelection | null;
) { policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const visibility = useMemo( const visibility = useMemo(
() => selectSidebarGroupVisibility(items, expanded, policy), () => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy], [expanded, items, policy, selection],
); );
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
@@ -4,7 +4,6 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime"; } from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react"; import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type { import type {
@@ -13,14 +12,14 @@ import type {
} from "../terminal/local-links/terminal-local-link-provider"; } from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste"; import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
find?: TerminalFindHandle; find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void; replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void; paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>; copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void; clear: () => void;
@@ -34,6 +33,7 @@ export interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>; ref: Ref<TerminalEmulatorHandle>;
streamKey: string; streamKey: string;
supportsTerminalInputModeReplay: boolean; supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string; testId?: string;
xtermTheme?: ITheme; xtermTheme?: ITheme;
scrollbackLines: number; scrollbackLines: number;
@@ -44,7 +44,6 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void; onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void; onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
@@ -1,19 +1,17 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { import {
Keyboard, Keyboard,
type LayoutChangeEvent, type LayoutChangeEvent,
PanResponder, PanResponder,
type PanResponderGestureState,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
View, View,
type GestureResponderEvent,
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { import {
TerminalInputModeTracker, TerminalInputModeTracker,
terminalInputModeStatesEqual, terminalInputModeStatesEqual,
@@ -48,13 +46,10 @@ import {
type TerminalSelectionViewport, type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection"; } from "../terminal/native-renderer/terminal-selection";
import { import {
TERMINAL_GESTURE_LONG_PRESS_MS, createTerminalSelectionController,
TERMINAL_GESTURE_TAP_TOLERANCE_PX, terminalSelectionHandles,
advanceTerminalScrollGesture, TERMINAL_SELECTION_GRIP_SIZE,
classifyTerminalGestureIntent, } from "../terminal/native-renderer/terminal-selection-controller";
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import { import {
forwardNativeTerminalKey, forwardNativeTerminalKey,
@@ -88,75 +83,8 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6", foreground: "#e6e6e6",
cursor: "#e6e6e6", cursor: "#e6e6e6",
}; };
interface NativeTerminalPanState { function initialNativeTerminalSize(): TerminalMeasuredSize {
lastDy: number; return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
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({ function NativeTerminalEmulator({
@@ -165,11 +93,11 @@ function NativeTerminalEmulator({
testId = "terminal-surface", testId = "terminal-surface",
xtermTheme = DEFAULT_XTERM_THEME, xtermTheme = DEFAULT_XTERM_THEME,
scrollbackLines, scrollbackLines,
characterWidths,
fontFamily, fontFamily,
fontSize, fontSize,
keyboardInset = 0, keyboardInset = 0,
isKeyboardVisible = false, isKeyboardVisible = false,
initialSnapshot = null,
onInput, onInput,
onTerminalKey, onTerminalKey,
onResize, onResize,
@@ -181,20 +109,18 @@ function NativeTerminalEmulator({
resizeRequestToken = 0, resizeRequestToken = 0,
}: TerminalEmulatorProps) { }: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null); const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey, initialSnapshot }); const mountInputRef = useRef({ streamKey });
if ( if (
resolveNativeTerminalMountAction({ resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey, mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey, nextStreamKey: streamKey,
}) === "mount" }) === "mount"
) { ) {
mountInputRef.current = { streamKey, initialSnapshot }; mountInputRef.current = { streamKey };
} }
const inputRef = useRef<TerminalInputHandle>(null); const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot)); const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef( const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const activeResizeClaimTokenRef = useRef(0); const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null); const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null); const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -202,29 +128,20 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null); const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null); const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel()); const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const selectionControllerRef = useRef<ReturnType<
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null); typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null); const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null); const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder()); 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 inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({ const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(), ...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false, applicationCursorKeys: false,
}); });
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => { const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null); const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null); const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false); const hasNotifiedSelectionRef = useRef(false);
@@ -288,6 +205,7 @@ function NativeTerminalEmulator({
}, []); }, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear(); const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range); commitSelectionRange(snapshot.range);
}, [commitSelectionRange]); }, [commitSelectionRange]);
@@ -298,20 +216,14 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback( const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => { (terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range); commitSelectionRange(snapshot.range);
}, },
[commitSelectionRange], [commitSelectionRange],
); );
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return ( return (
resolveMeasuredNativeTerminalSize({ resolveMeasuredNativeTerminalSize({
@@ -361,10 +273,14 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({ createNativeTerminalOutputDrain({
write: async (chunk) => { write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk); await terminal.write(chunk);
emitInputModeChange(); emitInputModeChange();
}, },
reset: () => { restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
terminal.reset(); terminal.reset();
emitInputModeChange(); emitInputModeChange();
}, },
@@ -415,11 +331,11 @@ function NativeTerminalEmulator({
return; return;
} }
const isFirstMeasuredSize = !hasNotifiedMeasuredSizeRef.current;
hasNotifiedMeasuredSizeRef.current = true; hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize; terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) { if (policyResult.measuredSizeChanged) {
terminalRef.current?.resize(policyResult.measuredSize); outputDrainRef.current?.resize();
schedulePaint();
} }
callbacksRef.current.onResize?.({ callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows, rows: policyResult.measuredSize.rows,
@@ -427,69 +343,62 @@ function NativeTerminalEmulator({
shouldClaim: policyResult.resizeClaim !== null, shouldClaim: policyResult.resizeClaim !== null,
forceClaim: policyResult.resizeClaim?.force, forceClaim: policyResult.resizeClaim?.force,
}); });
}, // Ready waits for the grid size, so the pane attaches with it and the daemon replays the
[schedulePaint], // terminal at that size instead of the PTY's.
); if (isFirstMeasuredSize) {
callbacksRef.current.onRendererReadyChange?.({
const resetTerminal = useCallback( streamKey: mountInputRef.current.streamKey,
(snapshot: TerminalState | null) => { isReady: true,
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, const resetTerminal = useCallback(() => {
resolveTerminalScreen, 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,
scrollbackLines, scrollbackLines,
clearSelection, characterWidths,
], });
); 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,
characterWidths,
clearSelection,
]);
const enqueueOutputText = useCallback( const enqueueOutputText = useCallback((text: string) => {
(text: string) => { if (text.length === 0) {
if (text.length === 0) { return;
return; }
} outputDrainRef.current?.enqueueText(text);
const inputModeUpdate = inputModeTrackerRef.current.feed(text); }, []);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const claimActiveTerminalSize = useCallback(() => { const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1; activeResizeClaimTokenRef.current += 1;
@@ -512,27 +421,7 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true }); const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text); enqueueOutputText(text);
}, },
restoreOutput: (data: TerminalOutputData) => { replay: (event) => outputDrainRef.current?.replay(event),
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) => { paste: (text: string) => {
if (text.length === 0) { if (text.length === 0) {
return; return;
@@ -563,7 +452,6 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset(); screenModelRef.current?.reset();
clearSelection(); clearSelection();
resetInputModeTracker(); resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint(); schedulePaint();
}, },
claimSize: claimActiveTerminalSize, claimSize: claimActiveTerminalSize,
@@ -582,19 +470,17 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction, claimActiveTerminalSizeForAction,
clearSelection, clearSelection,
resetInputModeTracker, resetInputModeTracker,
resetTerminal,
schedulePaint, schedulePaint,
], ],
); );
useEffect(() => { useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false; hasNotifiedMeasuredSizeRef.current = false;
resetTerminal(mountInputRef.current.initialSnapshot); resetTerminal();
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); emitMeasuredSize({ source: "measure" });
return () => { return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
clearSelectionLongPressTimeout(); selectionControllerRef.current?.cancel();
selectionPressRef.current = null;
if (paintFrameRef.current !== null) { if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current); cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null; paintFrameRef.current = null;
@@ -604,7 +490,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose(); terminalRef.current?.dispose();
terminalRef.current = null; terminalRef.current = null;
}; };
}, [clearSelectionLongPressTimeout, resetTerminal, streamKey]); }, [emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => { useEffect(() => {
if (focusRequestToken <= 0) return; if (focusRequestToken <= 0) return;
@@ -661,6 +547,7 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback( const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => { (metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics; metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" }); emitMeasuredSize({ source: "measure" });
}, },
[emitMeasuredSize], [emitMeasuredSize],
@@ -733,14 +620,6 @@ function NativeTerminalEmulator({
if (!coordinate) { if (!coordinate) {
return; 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 bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({ let snapshot = selectionModelRef.current.begin({
@@ -755,222 +634,61 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate], [commitSelectionRange, resolveSelectionCoordinate],
); );
const updateSelectionAtPoint = useCallback( const selectionController = useMemo(
(point: { x: number; y: number }) => { () =>
const terminal = terminalRef.current; createTerminalSelectionController({
if (!terminal || selectionPressRef.current?.status !== "selecting") { getGeometry: () => {
return; const viewport = resolveSelectionViewport();
} const metrics = metricsRef.current;
const coordinate = resolveSelectionCoordinate(point); return viewport && metrics
if (!coordinate) { ? { range: selectionRangeRef.current, viewport, metrics }
return; : null;
} },
const snapshot = selectionModelRef.current.update({ onBeginHandle: (endpoint, cols) =>
coordinate, selectionModelRef.current.beginEndpointDrag(endpoint, cols),
bounds: terminal.getBufferBounds(), onMoveHandle: (coordinate) => {
}); const terminal = terminalRef.current;
commitSelectionRange(snapshot.range); if (!terminal) return;
}, commitSelectionRange(
[commitSelectionRange, resolveSelectionCoordinate], selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
); .range,
);
const handlePanResponderScroll = useCallback( },
(currentDy: number) => { onLongPress: beginSelectionAtPoint,
const cellHeight = metricsRef.current?.cellHeight; onTap: focusTerminalFromTap,
if (!cellHeight || cellHeight <= 0) { onScroll: applyScroll,
return; schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
} cancel: (timer) => clearTimeout(timer),
const panState = panStateRef.current; }),
const update = advanceTerminalScrollGesture({ [
state: panState, applyScroll,
currentDy, beginSelectionAtPoint,
cellHeight, commitSelectionRange,
}); focusTerminalFromTap,
panState.lastDy = update.state.lastDy; resolveSelectionViewport,
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( const panResponder = useMemo(
() => () =>
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => 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) => { onPanResponderGrant: (event) => {
panStateRef.current = { const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
lastDy: 0, gestureStartRef.current = point;
rowRemainder: 0, selectionController.press(point);
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"; const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -981,6 +699,11 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null; const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled"; const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? ( const terminalGrid = terminalState ? (
<TerminalGridView <TerminalGridView
state={terminalState} state={terminalState}
@@ -997,6 +720,41 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}> <View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}> <View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid} {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 <TerminalInput
ref={inputRef} ref={inputRef}
isKeyboardVisible={isKeyboardVisible} isKeyboardVisible={isKeyboardVisible}
@@ -1039,6 +797,8 @@ const styles = StyleSheet.create({
nativeGrid: { nativeGrid: {
flex: 1, flex: 1,
}, },
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: { followButton: {
position: "absolute", position: "absolute",
right: 12, right: 12,
@@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { import {
@@ -28,6 +27,7 @@ import {
} from "../terminal/runtime/terminal-emulator-runtime"; } from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste"; import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type { import type {
TerminalLocalFileLinkSource, TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget, TerminalLocalFileLinkTarget,
@@ -40,8 +40,7 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle { export interface TerminalEmulatorHandle {
find?: TerminalFindHandle; find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void; writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void; replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void; paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>; copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void; clear: () => void;
@@ -98,6 +97,7 @@ interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>; ref: Ref<TerminalEmulatorHandle>;
streamKey: string; streamKey: string;
supportsTerminalInputModeReplay: boolean; supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string; testId?: string;
xtermTheme?: ITheme; xtermTheme?: ITheme;
scrollbackLines: number; scrollbackLines: number;
@@ -108,7 +108,6 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void; onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void; onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
@@ -147,16 +146,6 @@ declare global {
interface Window {} interface Window {}
} }
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
export default function TerminalEmulator({ export default function TerminalEmulator({
ref, ref,
streamKey, streamKey,
@@ -167,12 +156,12 @@ export default function TerminalEmulator({
cursor: "#e6e6e6", cursor: "#e6e6e6",
}, },
scrollbackLines, scrollbackLines,
characterWidths,
fontFamily, fontFamily,
fontSize, fontSize,
swipeGesturesEnabled = false, swipeGesturesEnabled = false,
onSwipeLeft, onSwipeLeft,
onSwipeRight, onSwipeRight,
initialSnapshot = null,
onFindRequest, onFindRequest,
onFindResult, onFindResult,
onInput, onInput,
@@ -197,6 +186,8 @@ export default function TerminalEmulator({
const fontSizeRef = useRef(fontSize); const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines); const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines; scrollbackLinesRef.current = scrollbackLines;
const characterWidthsRef = useRef(characterWidths);
characterWidthsRef.current = characterWidths;
fontFamilyRef.current = fontFamily; fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize; fontSizeRef.current = fontSize;
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
@@ -228,8 +219,6 @@ export default function TerminalEmulator({
onOpenLocalFileLink, onOpenLocalFileLink,
onFilePaste, onFilePaste,
}; };
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers); const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers; pendingModifiersRef.current = pendingModifiers;
@@ -253,17 +242,17 @@ export default function TerminalEmulator({
const data = args[0]; const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data }); if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
}, },
restoreOutput: (...args) => { replay: (event) => {
const data = args[0]; if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
}, if (event.type === "data" && event.data instanceof Uint8Array)
renderSnapshot: (...args) => { runtimeRef.current?.replay({ type: "data", data: event.data });
const state = args[0]; if (event.type === "begin")
if (state === null) { runtimeRef.current?.replay({
runtimeRef.current?.renderSnapshot({ state: null }); type: "begin",
} else if (isTerminalState(state)) { rows: typeof event.rows === "number" ? event.rows : undefined,
runtimeRef.current?.renderSnapshot({ state }); cols: typeof event.cols === "number" ? event.cols : undefined,
} });
}, },
paste: (...args) => { paste: (...args) => {
const text = args[0]; const text = args[0];
@@ -299,12 +288,7 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => { writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data }); runtimeRef.current?.write({ data });
}, },
restoreOutput: (data: TerminalOutputData) => { replay: (event) => runtimeRef.current?.replay(event),
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => { paste: (text: string) => {
pasteText(text); pasteText(text);
}, },
@@ -336,6 +320,10 @@ export default function TerminalEmulator({
runtimeRef.current?.setScrollback({ lines: scrollbackLines }); runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]); }, [scrollbackLines]);
useEffect(() => {
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
}, [characterWidths]);
useEffect(() => { useEffect(() => {
const root = rootRef.current; const root = rootRef.current;
if (!root || !swipeGesturesEnabled) { if (!root || !swipeGesturesEnabled) {
@@ -468,10 +456,10 @@ export default function TerminalEmulator({
}, },
}); });
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current }); runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
runtime.mount({ runtime.mount({
root, root,
host, host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current, scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current, theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current, fontFamily: fontFamilyRef.current,

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