Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andDominic Anaeto 02a58517f8 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>
2026-11-01 05:28:23 +00:00
Dominic Anaeto 0f59ccef40 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
2026-11-01 01:02:41 -04: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
165 changed files with 7881 additions and 1576 deletions
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
+30
View File
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
## Table of Contents
- [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)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [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](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.
+3 -1
View File
@@ -89,7 +89,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.
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.
@@ -507,6 +507,8 @@ interface AgentSession {
}
```
`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.
### Steps
+13 -3
View File
@@ -6,15 +6,25 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
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 conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
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.
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
## 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
+1 -1
View File
@@ -1 +1 @@
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
sha256-WPPLqWQzoUdjZNbZW6XWpy+A6XHXDyc0QW0FH6ubgMw=
+46 -45
View File
@@ -14740,16 +14740,52 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
"version": "6.1.0-beta.334",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz",
"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",
"workspaces": [
"addons/*"
@@ -37626,13 +37662,14 @@
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.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-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-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -37710,7 +37747,7 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -38489,15 +38526,6 @@
"@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": {
"version": "0.11.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
@@ -38541,34 +38569,6 @@
"@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": {
"version": "0.36.1",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
@@ -40690,7 +40690,8 @@
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
@@ -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 { test, expect, type Page } from "../support/fixtures";
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();
}
});
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 { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
attachFileFromMenu,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
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 ({
page,
}) => {
@@ -81,6 +81,8 @@ for (const folder of [
"docs/guides",
"docs/guides/",
"docs/指南",
"examples/next.js/",
"examples/next.js",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
@@ -92,6 +94,9 @@ for (const folder of [
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
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 page.addInitScript(() => {
localStorage.setItem(
@@ -38,6 +38,9 @@ import {
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostSslDisabled,
fillDirectHostAddress,
toggleDirectHostSsl,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
@@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => {
await expectAddHostMethodOptions(page);
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 fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
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();
}
});
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
await expectHiddenWorkspaceShortcuts(page, order);
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
await expectCollapsedWorkspaceShortcuts(page, order);
});
} finally {
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();
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 expect(rows).toHaveCount(7);
await page.keyboard.press("Alt+9");
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(
0,
);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
}
@@ -1,5 +1,6 @@
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";
@@ -82,6 +83,8 @@ async function fixtureTerminal(size) {
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),
);
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
}
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();
}
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> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
@@ -1,21 +1,21 @@
[
{
"output": "स्ते",
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"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": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"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,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 3, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
"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": "क्अ",
@@ -31,7 +31,7 @@
},
{
"output": "अ्क",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"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]
}
@@ -1,6 +1,5 @@
package sh.paseo.terminal
import android.os.Build
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
@@ -10,6 +9,7 @@ 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
@@ -43,18 +43,45 @@ class TerminalRowLayoutTest {
val output = row.getString("output")
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
continue
}
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate()
}
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* 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 or selection is drawn once per color, clipped to those cells' columns.
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded)
}
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
/** 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)
}
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val background =
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
if (background ushr 24 == 0) continue
fill.color = background
canvas.drawRect(
cellCol[cell] * cellWidth,
0f,
(cellCol[cell] + cellSpan[cell]) * cellWidth,
cellHeight,
fill,
)
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()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = foreground(row, selection, first, paints)
val color = cellForeground[first]
var last = first
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
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()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = foreground(row, selection, cell, paints)
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0xFFFFFF00.toInt()
var selectionForeground: Int = 0xFF000000.toInt()
var selectionBackground: Int = 0x472684FF
}
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
selectionForeground: number;
}
export interface PaseoTerminalCellMetrics {
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0xffff_ff00)),
selectionForeground: TerminalColor(Double(0xff00_0000)))
selectionBackground: TerminalColor(Double(0x4726_84ff)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground),
selectionForeground: TerminalColor(record.selectionForeground))
selectionBackground: TerminalColor(record.selectionBackground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
/// 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 background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
guard background.isPainted else { continue }
context.setFillColor(background.cgColor())
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
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 or selection is drawn once per color, clipped to those
// 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 = foreground(first, row: row, selection: selection, paint: paint)
let color = cellForeground[first]
var last = first
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
while last < group.lastCell && cellForeground[last + 1] == color {
last += 1
}
context.saveGState()
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = foreground(cell, row: row, selection: selection, paint: paint)
let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
+5 -4
View File
@@ -71,13 +71,14 @@
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.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-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-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.336",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -155,7 +156,7 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "6.1.0-beta.334",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
@@ -1,8 +1,16 @@
import type { QueryClient } from "@tanstack/react-query";
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";
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 {
provider: AgentProvider;
@@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) {
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 }) {
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 { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import {
agentCommandsQueryKey,
agentCommandsQueryRoot,
draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd,
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", () => {
it("shares draft commands across draft tabs with the same provider setup", () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
@@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
}
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 () => {
const client = createClient(
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 type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
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 { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
const SESSION_COMMANDS_STALE_TIME = 60_000;
import {
agentCommandsQueryKey,
sessionCommandQueryPolicy,
type AgentCommandsDraftConfig,
} from "./cache";
export interface AgentSlashCommand {
name: string;
@@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: {
agentId: input.agentId,
draftConfig: input.draftConfig,
});
if (response.error) throw new Error(response.error);
return response.commands as AgentSlashCommand[];
}
@@ -62,8 +66,16 @@ export function useAgentCommandsQuery({
const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(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 }),
queryFn: async () => {
if (!client) {
@@ -72,7 +84,15 @@ export function useAgentCommandsQuery({
return fetchAgentCommands({ client, 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,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
});
@@ -84,7 +104,7 @@ export function useAgentCommandsQuery({
return {
commands: query.data ?? [],
isLoading,
isError: query.isError,
isError: query.isError && query.data === undefined,
error: query.error,
};
}
+1
View File
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() =>
createInlinePathOpener({
workspaceRoot,
resolveRelativeFiles: true,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
opened.push(location.path);
},
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
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(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,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
interface PathOpenInput {
file?: string;
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
client: {
listDirectory(root: string, path: string): Promise<unknown>;
getDirectorySuggestions: GetDirectorySuggestions;
} | null;
resolveRelativeFiles?: boolean;
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} 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({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
path: resolved.file,
lineStart: resolvedTarget.lineStart,
lineEnd: resolvedTarget.lineEnd,
});
if (location) input.openFile(location, disposition);
}
@@ -7,6 +7,7 @@ import {
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
resolveMissingNestedRelativeFile,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
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", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
@@ -5,6 +5,7 @@ import {
type InlinePathTarget,
} from "./parse";
import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export interface AssistantFileLinkSource {
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(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
}
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 type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
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(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
@@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
advancedTarget.current = new PairingTargetTracker("", true);
try {
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
@@ -505,6 +476,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityRole="checkbox"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
aria-checked={useTls}
testID="direct-ssl-toggle"
>
<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 } : {}),
});
}
@@ -35,11 +35,15 @@ export interface MarkdownImageContext {
filePath?: string;
}
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
) {
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
[text, context.workspaceRoot],
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
);
const sources = [...new Set(prepared.images.map((image) => image.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 { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
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 { 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", () => {
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";
@@ -6,6 +6,7 @@ import {
useAssistantFileLinkEnvironment,
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
selectable?: boolean;
streaming?: boolean;
onLinkPress?: (url: string) => unknown;
agentCommands?: boolean;
}
/** 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,
[completeLines],
);
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
if (!streaming || boundary === 0) return <Part {...props} />;
return (
<MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} />
<Part {...props} text={props.text.slice(0, boundary)} />
<Part {...props} text={props.text.slice(boundary)} />
</MarkdownBlockStack>
);
});
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
const commandHints = useAgentCommandHints();
return <MarkdownPart {...props} commandHints={commandHints} />;
});
const MarkdownPart = memo(function MarkdownPart({
text,
compact = false,
@@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({
selectable = true,
streaming = false,
onLinkPress,
agentCommands: _agentCommands,
commandHints = null,
...imageContext
}: MarkdownRendererProps) {
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const client =
@@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({
(environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) }
: undefined);
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
text,
{
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
},
commandHints?.names,
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
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);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
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 previousEnds = new Map<number, number>();
// 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);
}
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;
}
@@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean } = {},
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -122,7 +130,9 @@ export function prepareMarkdownSource(
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
);
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
+1
View File
@@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({
>
<MarkdownRenderer
text={revealedMessage}
agentCommands
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
client={client}
workspaceRoot={workspaceRoot}
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
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.canToggle).toBe(count > 7);
});
@@ -471,7 +471,9 @@ describe("sidebar group visibility", () => {
"keeps row twelve visible for %s without filling gaps",
(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([
"row-1",
"row-2",
@@ -492,7 +494,11 @@ describe("sidebar group visibility", () => {
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() =>
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
useLimitedSidebarGroup({
items,
selection: null,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
);
expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual(
@@ -512,7 +518,7 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -527,7 +533,7 @@ describe("sidebar group visibility", () => {
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup(rows, policy),
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
@@ -568,7 +574,7 @@ describe("sidebar group visibility", () => {
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
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.visibleItems).toHaveLength(10);
});
@@ -592,7 +598,12 @@ describe("sidebar group visibility", () => {
lifecycle: "active" as const,
};
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] } },
);
expect(result.current.visibleItems).toHaveLength(7);
@@ -630,7 +641,7 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
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.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
@@ -1598,11 +1598,16 @@ function ProjectBlock({
[workspaceEntriesByKey],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1761,28 +1766,33 @@ function ProjectBlock({
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
projectChildren = (
<>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
<>
{leadingWorkspaceRow}
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
</>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{leadingWorkspaceRow}
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
@@ -2188,11 +2198,15 @@ function ProjectModeList({
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats);
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
});
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
@@ -2395,37 +2409,38 @@ function ProjectModeList({
[renderProjectBlock],
);
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
const renderPinnedChatRow = useCallback(
(
workspace: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
),
[
activeWorkspaceSelection,
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 =
projects.length === 0 ? (
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
@@ -2473,6 +2504,7 @@ function ProjectModeList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
<DraggableList
testID="sidebar-pinned-list"
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 { 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> =
| { kind: "pinned" }
@@ -16,18 +40,45 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
};
}
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
policy: SidebarGroupVisibilityPolicy<T>,
) {
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline = items.filter(
(item, index) =>
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
function isOpenWorkspace(
item: SidebarGroupItem,
selection: ActiveWorkspaceSelection | null,
): boolean {
return (
selection !== null &&
item.serverId === selection.serverId &&
item.workspaceId === selection.workspaceId
);
}
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 {
visibleItems: expanded ? items.slice() : baseline,
canToggle: baseline.length < items.length,
leadingItem,
visibleItems: baseline,
canToggle,
};
}
@@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({
(state) => state.togglePinnedCollapsed,
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
const renderPinnedWorkspaceRow = useCallback(
(
workspace: SidebarWorkspaceEntry,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
@@ -184,9 +189,9 @@ export function SidebarStatusWorkspaceList({
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
),
[
@@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({
supportsPinningByServerId,
],
);
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
renderPinnedWorkspaceRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
}),
[renderPinnedWorkspaceRow],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
@@ -345,12 +365,37 @@ function StatusGroupRows({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
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 (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -360,22 +405,8 @@ function StatusGroupRows({
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.key}`}
>
{visibleWorkspaces.map((workspace) => (
<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}
/>
))}
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
{visibleWorkspaces.map(renderWorkspaceRow)}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1,19 +1,32 @@
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibility,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
const PINNED_POLICY = { kind: "pinned" } as const;
export function useLimitedSidebarGroup<T>(
items: readonly T[],
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
) {
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
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 visibility = useMemo(
() => selectSidebarGroupVisibility(items, expanded, policy),
[expanded, items, policy],
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
@@ -12,6 +12,7 @@ import type {
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -32,6 +33,7 @@ export interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -1,14 +1,13 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import {
Keyboard,
type LayoutChangeEvent,
PanResponder,
type PanResponderGestureState,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -47,13 +46,10 @@ import {
type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
createTerminalSelectionController,
terminalSelectionHandles,
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
@@ -87,38 +83,6 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6",
cursor: "#e6e6e6",
};
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
@@ -129,6 +93,7 @@ function NativeTerminalEmulator({
testId = "terminal-surface",
xtermTheme = DEFAULT_XTERM_THEME,
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
keyboardInset = 0,
@@ -163,19 +128,14 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
const selectionControllerRef = useRef<ReturnType<
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 paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(),
@@ -245,6 +205,7 @@ function NativeTerminalEmulator({
}, []);
const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range);
}, [commitSelectionRange]);
@@ -255,20 +216,14 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range);
},
[commitSelectionRange],
);
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return (
resolveMeasuredNativeTerminalSize({
@@ -419,6 +374,7 @@ function NativeTerminalEmulator({
rows: size.rows,
cols: size.cols,
scrollbackLines,
characterWidths,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
@@ -433,6 +389,7 @@ function NativeTerminalEmulator({
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
characterWidths,
clearSelection,
]);
@@ -523,8 +480,7 @@ function NativeTerminalEmulator({
emitMeasuredSize({ source: "measure" });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
selectionControllerRef.current?.cancel();
if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null;
@@ -534,7 +490,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
}, [emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => {
if (focusRequestToken <= 0) return;
@@ -591,6 +547,7 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" });
},
[emitMeasuredSize],
@@ -663,14 +620,6 @@ function NativeTerminalEmulator({
if (!coordinate) {
return;
}
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({
@@ -685,222 +634,61 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate],
);
const updateSelectionAtPoint = useCallback(
(point: { x: number; y: number }) => {
const terminal = terminalRef.current;
if (!terminal || selectionPressRef.current?.status !== "selecting") {
return;
}
const coordinate = resolveSelectionCoordinate(point);
if (!coordinate) {
return;
}
const snapshot = selectionModelRef.current.update({
coordinate,
bounds: terminal.getBufferBounds(),
});
commitSelectionRange(snapshot.range);
},
[commitSelectionRange, resolveSelectionCoordinate],
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
const selectionController = useMemo(
() =>
createTerminalSelectionController({
getGeometry: () => {
const viewport = resolveSelectionViewport();
const metrics = metricsRef.current;
return viewport && metrics
? { range: selectionRangeRef.current, viewport, metrics }
: null;
},
onBeginHandle: (endpoint, cols) =>
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
const terminal = terminalRef.current;
if (!terminal) return;
commitSelectionRange(
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
.range,
);
},
onLongPress: beginSelectionAtPoint,
onTap: focusTerminalFromTap,
onScroll: applyScroll,
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
cancel: (timer) => clearTimeout(timer),
}),
[
applyScroll,
beginSelectionAtPoint,
commitSelectionRange,
focusTerminalFromTap,
resolveSelectionViewport,
],
);
selectionControllerRef.current = selectionController;
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
gestureStartRef.current = point;
selectionController.press(point);
},
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}),
[
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
[selectionController],
);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -911,6 +699,11 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? (
<TerminalGridView
state={terminalState}
@@ -927,6 +720,41 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
@@ -969,6 +797,8 @@ const styles = StyleSheet.create({
nativeGrid: {
flex: 1,
},
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: {
position: "absolute",
right: 12,
@@ -27,6 +27,7 @@ import {
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -96,6 +97,7 @@ interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -154,6 +156,7 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
@@ -183,6 +186,8 @@ export default function TerminalEmulator({
const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
const characterWidthsRef = useRef(characterWidths);
characterWidthsRef.current = characterWidths;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
@@ -315,6 +320,10 @@ export default function TerminalEmulator({
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
}, [characterWidths]);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -447,6 +456,7 @@ export default function TerminalEmulator({
},
});
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
runtime.mount({
root,
host,
@@ -1,5 +1,6 @@
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -304,6 +305,11 @@ export function TerminalPane({
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
? "graphemes"
: "legacy",
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
@@ -1171,6 +1177,7 @@ export function TerminalPane({
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={terminalStreamKey}
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
characterWidths={characterWidths}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
@@ -2,7 +2,7 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
@@ -126,17 +126,13 @@ afterEach(async () => {
});
});
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
({ useAgentInputDraft } = await import("./input-draft"));
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
const { useAgentInputDraft } = await import("./input-draft");
describe("useAgentInputDraft live contract", () => {
beforeEach(() => {
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/agent-commands";
import {
useAgentFormState,
type CreateAgentInitialValues,
@@ -2,6 +2,31 @@ import { describe, expect, it } from "vitest";
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
describe("composer editing session", () => {
it("appends commands to the current editor buffer and focuses it", () => {
const session = new ComposerEditingSession();
let text = "draft typed since publication";
let focuses = 0;
let selection = { start: 0, end: 0 };
const detach = session.attachEditor({
getText: () => text,
replaceText: (next, nextSelection) => {
text = next;
selection = nextSelection;
},
focus: () => {
focuses += 1;
},
});
session.insertCommand("/review");
expect(selection).toEqual({ start: text.length, end: text.length });
session.insertCommand("/test");
expect(text).toBe("draft typed since publication /review /test ");
expect(focuses).toBe(2);
expect(selection).toEqual({ start: text.length, end: text.length });
detach();
session.insertCommand("/ignored");
expect(text).toBe("draft typed since publication /review /test ");
});
it("publishes fullscreen changes once per transition", () => {
const session = new ComposerEditingSession();
const changes: boolean[] = [];
@@ -30,10 +30,29 @@ export interface ComposerRetainedEditor {
*/
export class ComposerEditingSession {
private fullscreen = false;
private editor: {
getText(): string;
replaceText(text: string, selection: ComposerSelection): void;
focus(): void;
} | null = null;
private retained: ComposerRetainedEditor | null = null;
private listeners = new Set<() => void>();
constructor(private readonly onChange?: () => void) {}
getFullscreen = () => this.fullscreen;
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
this.editor = editor;
return () => {
if (this.editor === editor) this.editor = null;
};
}
insertCommand(command: string) {
const editor = this.editor;
if (!editor) return;
const text = editor.getText();
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
editor.focus();
}
/** Called by the unmounting editor; the next mount of this session takes it. */
retainEditor(retained: ComposerRetainedEditor) {
this.retained = retained;
+1 -1
View File
@@ -71,7 +71,7 @@ import {
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/agent-commands";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
+10
View File
@@ -1407,6 +1407,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
);
useLayoutEffect(
() =>
editingSession.attachEditor({
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
focus: () => textInputRef.current?.focus(),
}),
[editingSession, replaceText],
);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./provider-snapshot-cache";
import { queryClient as singletonQueryClient } from "./query-client";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { agentCommandsQueryRoot } from "@/agent-commands/cache";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
+11 -9
View File
@@ -1,11 +1,12 @@
import type { OwnedSubscription } from "@getpaseo/client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
import type {
ListTerminalsResponse,
MutableDaemonConfig,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
import { shareCheckoutDiff } from "@/git/diff-sharing";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
@@ -186,10 +187,10 @@ export async function applyProvidersSnapshotUpdate(input: {
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey);
const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
let announcement: typeof snapshot | undefined = snapshot;
void input.queryClient.cancelQueries({ queryKey, exact: true });
await input.queryClient.fetchQuery({
const current = await input.queryClient.fetchQuery({
queryKey,
staleTime: 0,
structuralSharing: false,
@@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: {
});
},
});
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) {
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
exact: false,
});
}
void invalidateCommandsForProviderChange({
queryClient: input.queryClient,
serverId: input.serverId,
cwd,
previous: previous?.entries,
current: current.entries,
});
}
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
+2 -2
View File
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
"initialData" | "placeholderData" | "queryFn" | "staleTime"
> & {
dataShape: "list" | "value";
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
dataShape,
},
},
refetchOnMount: immutableWhen ? true : "always",
refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
staleTime: immutableWhen
? (query) =>
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
@@ -16,7 +16,7 @@ import {
resolveWorkingDiffComparison,
selectWorkingDiffComparison,
} from "@/git/working-diff-comparison";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
@@ -8,7 +8,7 @@ import {
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import {
useAgentCommandsQuery,
useAutocompleteCommands,
type AgentSlashCommand,
type DraftCommandConfig,
type DraftCommandTarget,
} from "./use-agent-commands-query";
} from "@/agent-commands";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store";
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
autocompleteCwd,
});
const commandsQuery = useAgentCommandsQuery({
const commandsQuery = useAutocompleteCommands({
serverId,
agentId,
enabled: mode === "command" && canLoadCommands && canListCommands,
@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
@@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
}
).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
});
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
await import("./use-agent-history");
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
@@ -1,4 +1,4 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -8,7 +8,7 @@ import {
expandProviderSnapshot,
} from "@getpaseo/protocol/provider-snapshot-codec";
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache";
import { providerSnapshotIcons } from "@/data/provider-icons";
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
import {
@@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => {
};
}
it("refreshes active commands only when their provider changes after discovery", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
let requests = 0;
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => {
requests++;
return [{ name: "review" }];
},
staleTime: Infinity,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(1);
const codex = codexEntry("ready", [readyCodexModel]);
const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry;
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("loading"), other], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codex, other], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[
{ ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" },
{ ...other, status: "ready" },
],
"/repo-a",
),
});
expect(requests).toBe(1);
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-b"),
providersSnapshot([codex]),
);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])],
"/repo-b",
),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[
codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]),
{ ...other, status: "ready" },
],
"/repo-a",
),
});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(2);
unsubscribe();
queryClient.clear();
});
it("retries a failed command discovery when its provider first becomes ready", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
let ready = false;
let requests = 0;
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => {
requests++;
if (!ready) throw new Error("provider unavailable");
return [{ name: "review" }];
},
retry: false,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
await observer.refetch({ cancelRefetch: false });
expect(observer.getCurrentResult().isError).toBe(true);
ready = true;
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"),
});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(2);
expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]);
unsubscribe();
queryClient.clear();
});
it("refreshes again when its provider changes during an active refresh", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
queryClient.setQueryData(key, [{ name: "old" }]);
let requests = 0;
const waiting = new Promise<{ name: string }[]>(() => {});
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]),
staleTime: Infinity,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-a"),
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
);
const send = (label: string) =>
applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"),
});
await send("First change");
expect(requests).toBe(1);
await send("Second change");
expect(requests).toBe(2);
await observer.refetch({ cancelRefetch: false });
expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]);
unsubscribe();
queryClient.clear();
});
it("routes updates to the home query cache when the message carries no cwd", async () => {
await applyProvidersSnapshotUpdate({
client: createClient(),
@@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => {
draftConfig: { provider: "codex", cwd: "/repo-a" },
});
queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]);
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-a"),
providersSnapshot([codexEntry("ready")]),
);
await applyProvidersSnapshotUpdate({
client: createClient(),
+34 -1
View File
@@ -28,6 +28,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { RetainedChatContent } from "./retained-chat-content";
import { Composer } from "@/composer";
import { AgentCommandsProvider } from "@/agent-commands";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</ChatSurface>
);
return (
const content = (
<RewindComposerRestoreProvider
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
@@ -1274,8 +1275,40 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</View>
</RewindComposerRestoreProvider>
);
return wrapAgentCommandProvider({
enabled: showsComposer && hasActiveComposer,
serverId,
agentId,
editingSession: composerEditingSession,
children: content,
});
});
function wrapAgentCommandProvider({
enabled,
serverId,
agentId,
editingSession,
children,
}: {
enabled: boolean;
serverId: string;
agentId: string;
editingSession: ComposerEditingSession;
children: ReactNode;
}) {
return (
<AgentCommandsProvider
serverId={serverId}
agentId={agentId}
editingSession={editingSession}
enabled={enabled}
>
{children}
</AgentCommandsProvider>
);
}
function ChatSurface({
children,
disabled,
@@ -5,7 +5,7 @@ import type {
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/agent-commands";
import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog";
@@ -16,7 +16,8 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { deriveProjectPlacementFromCwd } from "@/utils/project-placement";
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
import { useWorkspaceLabels } from "@/workspace-labels";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
@@ -41,12 +42,15 @@ class FakeDirectoryClient {
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
observeAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
observeWorkspacesCalls = 0;
lastWorkspaceOptions: unknown;
listProjectsCalls = 0;
lastProjectOptions: unknown;
projectResult: ProjectListResult | null = null;
readonly listSnapshots: Promise<unknown>[] = [];
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
@@ -151,15 +155,22 @@ class FakeDirectoryClient {
}
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
this.on("agent_update", receive),
this.observeAgentsCalls += 1;
const subscription = subscriptionFixture(
this.fetchAgents({ ...options, subscribe: {} }),
(receive) => this.on("agent_update", receive),
);
this.listSnapshots.push(subscription.ready);
return subscription;
}
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
return subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
this.observeWorkspacesCalls += 1;
const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
this.on("workspace_update", receive),
);
this.listSnapshots.push(subscription.ready);
return subscription;
}
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
@@ -592,6 +603,83 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it.each([
["full", "before"],
["full", "after"],
["route", "before"],
["route", "after"],
] as const)(
"restores live directory updates when %s demand returns %s refresh completion",
async (demand, timing) => {
const serverId = `released-directory-${demand}-${timing}`;
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
const source = {};
function setDemand(demanded: boolean): void {
if (demand === "full") directory.setDemand(source, demanded);
else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []);
}
const releaseProjects = client.holdProjectFetch();
const projects: ProjectListResult = { requestId: "projects", projects: [] };
try {
setDemand(true);
const refresh = directory.refreshDemand();
await expect
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
.toEqual([1, 1]);
await Promise.all(client.listSnapshots);
setDemand(false);
if (timing === "before") setDemand(true);
releaseProjects(projects);
await refresh;
if (timing === "after") setDemand(true);
await expect
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
.toEqual([2, 2]);
client.emit({
type: "agent_update",
payload: {
kind: "upsert",
agent: {
...projectAgentSnapshot(createAgent(serverId, "agent-1")),
title: "Live title",
},
project: deriveProjectPlacementFromCwd("/repo"),
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" },
},
});
await expect
.poll(() => {
const session = useSessionStore.getState().sessions[serverId];
return [
session.agents.get("agent-1")?.title,
session.workspaces.get("workspace-1")?.name,
];
})
.toEqual(["Live title", "Live workspace"]);
} finally {
directory.dispose();
}
},
);
it("publishes cached directory data before the authoritative request completes", async () => {
const serverId = "cached-directory";
serverIds.add(serverId);
@@ -160,6 +160,7 @@ export class DirectorySync {
private readonly fullDemandSources = new Set<object>();
private readonly scriptStatusDemand = new Map<object, string>();
private demandRefresh: Promise<void> | null = null;
private subscriptionGeneration = 0;
private satisfiedDemandSource: DirectorySourceToken | null = null;
private cursors: DirectoryCheckpoint = {};
@@ -262,6 +263,7 @@ export class DirectorySync {
}
private releaseSubscriptions(): void {
this.subscriptionGeneration += 1;
for (const subscription of [
this.agentSubscription,
this.workspaceSubscription,
@@ -362,6 +364,7 @@ export class DirectorySync {
this.observeDirectoryEvents();
this.observeScriptStatusEvents();
const source = this.connection.source;
const subscriptionGeneration = this.subscriptionGeneration;
if (
!force &&
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
@@ -378,17 +381,19 @@ export class DirectorySync {
]).then(() => undefined);
this.demandRefresh = refresh
.then(() => {
this.satisfiedDemandSource = source;
if (this.subscriptionGeneration === subscriptionGeneration) {
this.satisfiedDemandSource = source;
}
return undefined;
})
.finally(() => {
this.demandRefresh = null;
const current = this.connection.source;
if (
this.hasDemand() &&
(current.clientGeneration !== source.clientGeneration ||
current.connectionEpoch !== source.connectionEpoch)
) {
const sourceChanged =
current.clientGeneration !== source.clientGeneration ||
current.connectionEpoch !== source.connectionEpoch;
const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration;
if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) {
void this.requestDemandRefresh().catch(() => undefined);
}
});
@@ -0,0 +1,9 @@
/**
* The table a terminal buffer measures characters with.
*
* The daemon answers cursor reports and replays the screen from its own buffer,
* so the app measures with the table the host advertises. `graphemes` gives each
* emoji cluster one two-column cell. `legacy` is what each renderer used before
* hosts advertised grapheme widths. See docs/terminal-rendering.md.
*/
export type TerminalCharacterWidths = "graphemes" | "legacy";
@@ -48,8 +48,7 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: 0xffffff00,
foreground: 0xff000000,
background: 0x472684ff,
} as const;
function build256Palette(theme: ITheme): number[] {
@@ -159,4 +159,52 @@ describe("native headless terminal state", () => {
{ char: "B", width: 1 },
]);
});
// Apple Color Emoji and Noto Color Emoji are two cells wide. The native
// renderer scales a cluster to its column slot, so a one-column emoji
// renders at half size. Each emoji cluster must occupy one wide cell.
test.each([
["presentation emoji", "😀"],
["thumbs up", "👍"],
["check mark", "✅"],
["VS16 heart", "❤️"],
["skin-tone modifier", "👋🏽"],
["regional-indicator flag", "🇰🇷"],
["ZWJ family", "👨‍👩‍👧"],
["ZWJ technologist", "👩‍💻"],
])("gives a %s cluster one two-column cell", async (_name, cluster) => {
const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 });
await terminal.write(`A${cluster}B`);
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
{ char: "A", width: 1 },
{ char: cluster, width: 2 },
{ char: " ", width: 0 },
{ char: "B", width: 1 },
]);
expect(terminal.getViewportState().cursor.col).toBe(4);
});
// A host without grapheme widths answers cursor reports and replays with
// xterm's default table. The native buffer matches it so the cursor lands
// where the host put it.
test("measures with xterm's default table for hosts without grapheme widths", async () => {
const terminal = createNativeHeadlessTerminal({
rows: 2,
cols: 12,
characterWidths: "legacy",
});
await terminal.write("A😀B");
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
{ char: "A", width: 1 },
{ char: "😀", width: 1 },
{ char: "B", width: 1 },
]);
expect(terminal.getViewportState().cursor.col).toBe(3);
});
});
@@ -1,3 +1,5 @@
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import type { TerminalCharacterWidths } from "../character-widths";
import {
Terminal as HeadlessTerminal,
type IBufferCell,
@@ -17,6 +19,8 @@ export interface NativeHeadlessTerminalOptions {
rows: number;
cols: number;
scrollbackLines?: number;
/** Defaults to `graphemes`, the table current daemons use. */
characterWidths?: TerminalCharacterWidths;
}
export interface NativeHeadlessTerminal {
@@ -297,6 +301,12 @@ export function createNativeHeadlessTerminal(
scrollback: options.scrollbackLines ?? 1000,
allowProposedApi: true,
});
// Grapheme widths give each emoji cluster one two-column cell, the width the
// native renderer draws it at. Legacy hosts measure with xterm's default
// table, so their cursor reports and replay stay consistent with it.
if ((options.characterWidths ?? "graphemes") === "graphemes") {
terminal.loadAddon(new UnicodeGraphemesAddon());
}
const decoder = new TextDecoder();
let coordinateEpoch = 0;
const coordinateStorage = new Map<object, TerminalCoordinateStorage>();
@@ -90,18 +90,16 @@ describe("native terminal frame", () => {
test("keeps combining marks and joiners inside the cell xterm assigned them", async () => {
const { frame } = await frameFor("éx̸ 각 क्ष 👩‍💻");
expect(cellLayout(frame.rows[0]).slice(0, 11)).toEqual([
expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([
{ text: "é", col: 0, span: 1 },
{ text: "x̸", col: 1, span: 1 },
{ text: " ", col: 2, span: 1 },
{ text: "각", col: 3, span: 2 },
{ text: " ", col: 5, span: 1 },
{ text: "क्", col: 6, span: 1 },
{ text: "ष", col: 7, span: 1 },
{ text: " ", col: 8, span: 1 },
{ text: "👩‍", col: 9, span: 1 },
{ text: "💻", col: 10, span: 1 },
{ text: " ", col: 11, span: 1 },
{ text: "क्ष", col: 6, span: 1 },
{ text: " ", col: 7, span: 1 },
{ text: "👩‍💻", col: 8, span: 2 },
{ text: " ", col: 10, span: 1 },
]);
});
@@ -237,7 +235,7 @@ test("builds native frames after replay commits at captured geometry and the lat
expect(frame.columns).toBe(50);
expect(frame.rows).toHaveLength(10);
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]);
expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]);
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
} finally {
drain.dispose();
@@ -77,7 +77,6 @@ export function TerminalGridView({
background: resolver.backgroundColor,
cursor: resolver.cursorColor,
selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background,
selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}),
[resolver],
);
@@ -0,0 +1,411 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
createTerminalSelectionModel,
extractTerminalSelectedText,
resolveTerminalSelectionRects,
} from "./terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
} from "./terminal-selection-controller";
describe("selection handle controller", () => {
it("preserves the grab offset while moving a handle", () => {
const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 };
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 23, y: 15 });
controller.move({ x: 63, y: 15 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 5 }]);
});
});
it("selects on hold, preserves a handle tap, and cancels an active drag", () => {
const actions: string[] = [];
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint) => actions.push(endpoint),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: () => actions.push("scroll"),
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 90, y: 80 });
(timer as (() => void) | null)?.();
controller.release();
controller.press({ x: 20, y: 25 });
controller.release();
controller.press({ x: 20, y: 25 });
controller.cancel();
controller.move({ x: 50, y: 25 });
expect(actions).toEqual(["long", "start", "start"]);
});
it("scrolls selection into history while holding a handle at the edge", () => {
let firstRow = 10;
let timer: (() => void) | null = null;
const coordinates: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (point) => coordinates.push(point),
onLongPress: () => {},
onTap: () => {},
onScroll: (direction) => {
if (direction === "up") firstRow--;
},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 20, y: 25 });
controller.move({ x: 20, y: 1 });
(timer as (() => void) | null)?.();
(timer as (() => void) | null)?.();
controller.release();
expect(coordinates).toEqual([
{ row: 10, col: 2 },
{ row: 9, col: 2 },
{ row: 8, col: 2 },
]);
expect(timer).toBeNull();
});
it("scrolls outside handles without clearing selection and clears on an outside tap", () => {
const actions: string[] = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => actions.push("handle"),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: (direction, rows) => actions.push(`${direction}:${rows}`),
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 95, y: 70 });
controller.move({ x: 95, y: 90 });
controller.release();
controller.press({ x: 95, y: 70 });
controller.release();
expect(actions).toEqual(["up:2", "tap"]);
});
it("extends the selection after a long press and drag", () => {
let timer: (() => void) | null = null;
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: null,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 25, y: 25 });
(timer as (() => void) | null)?.();
controller.move({ x: 65, y: 35 });
controller.release();
expect(moves).toEqual([{ row: 3, col: 6 }]);
});
it.each([
{
endpoint: "start" as const,
startX: 20,
crossedX: 80,
crossedText: "gh",
crossedStart: 6,
crossedEnd: 7,
backX: 10,
backText: "bcdef",
backStart: 1,
backEnd: 5,
},
{
endpoint: "end" as const,
startX: 60,
crossedX: 10,
crossedText: "b",
crossedStart: 1,
crossedEnd: 1,
backX: 80,
backText: "cdefgh",
backStart: 2,
backEnd: 7,
},
])("tracks the $endpoint handle through crossing and back", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
const check = (start: number, end: number, text: string) => {
const range = model.getSnapshot().range;
expect(range).toEqual({
start: { row: 0, col: start },
end: { row: 0, col: end },
coordinateEpoch: bounds.coordinateEpoch,
});
expect(terminalSelectionHandles(geometry())).toMatchObject([
{ endpoint: "start", x: start * 10, y: 5 },
{ endpoint: "end", x: (end + 1) * 10, y: 5 },
]);
expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([
{
key: `0:${start}:${end}`,
x: start * 10,
y: 0,
width: (end - start + 1) * 10,
height: 10,
},
]);
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text);
};
controller.press({ x: scenario.startX, y: 5 });
controller.move({ x: scenario.crossedX, y: 5 });
check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText);
controller.move({ x: scenario.backX, y: 5 });
check(scenario.backStart, scenario.backEnd, scenario.backText);
controller.release();
} finally {
terminal.dispose();
}
});
it.each([
{
endpoint: "end" as const,
initialRow: 0,
targetRow: 1,
targetX: 0,
expectedRow: 0,
expectedCol: 9,
text: "cdefghij",
},
{
endpoint: "start" as const,
initialRow: 1,
targetRow: 0,
targetX: 100,
expectedRow: 1,
expectedCol: 0,
text: "KLMNOP",
},
])("preserves a $endpoint boundary at a row edge", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij\r\nKLMNOPQRST");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds });
model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 });
controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 });
const range = model.getSnapshot().range;
expect(range?.[scenario.endpoint]).toEqual({
row: scenario.expectedRow,
col: scenario.expectedCol,
});
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text);
expect(
terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint),
).toMatchObject({
x: scenario.endpoint === "end" ? 100 : 0,
y: scenario.expectedRow * 10 + 5,
});
controller.release();
} finally {
terminal.dispose();
}
});
it("cancels a drag when buffer coordinates are invalidated", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
model.begin({ coordinate: { row: 2, col: 2 }, bounds });
model.update({ coordinate: { row: 2, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const controller = createTerminalSelectionController({
getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 20, y: 25 });
const previous = model.getSnapshot().range;
const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } });
if (previous && !snapshot.range) controller.cancel();
controller.move({ x: 80, y: 25 });
controller.release();
expect(model.getSnapshot().range).toBeNull();
});
it("preserves a newly selected word through small movement at the viewport edge", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: model.getSnapshot().range,
viewport: { firstRow: 0, rows: 10, cols: 20 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 6 }, bounds });
},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 45, y: 5 });
const fire = timer as (() => void) | null;
timer = null;
fire?.();
controller.move({ x: 44, y: 4 });
controller.release();
expect(model.getSnapshot().range).toEqual({
start: { row: 0, col: 2 },
end: { row: 0, col: 6 },
coordinateEpoch: 0,
});
expect(timer).toBeNull();
});
it("places mirrored teardrop grips below the selected row", () => {
const handles = terminalSelectionHandles({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
});
expect(handles).toMatchObject([
{ endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" },
{ endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" },
]);
});
it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => {
const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 };
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const handles = terminalSelectionHandles({ range, viewport, metrics });
expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([
{ gripX: 9, gripY: 19 },
{ gripX: 91, gripY: 81 },
]);
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({ range, viewport, metrics }),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 9, y: 19 });
controller.move({ x: 19, y: 29 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 1 }]);
});
@@ -0,0 +1,208 @@
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
import type {
TerminalBufferCoordinate,
TerminalSelectionPoint,
TerminalSelectionRange,
TerminalSelectionViewport,
} from "./terminal-selection";
export const TERMINAL_SELECTION_GRIP_SIZE = 18;
const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
type Endpoint = "start" | "end";
interface Geometry {
range: TerminalSelectionRange | null;
viewport: TerminalSelectionViewport;
metrics: TerminalGridCellMetrics;
}
interface Ports {
getGeometry(): Geometry | null;
onBeginHandle(endpoint: Endpoint, cols: number): void;
onMoveHandle(coordinate: TerminalBufferCoordinate): void;
onLongPress(point: TerminalSelectionPoint): void;
onTap(): void;
onScroll(direction: "up" | "down", rows: number): void;
schedule(callback: () => void, delay: number): number;
cancel(timer: number): void;
}
export function terminalSelectionHandles(geometry: Geometry): Array<{
endpoint: Endpoint;
x: number;
y: number;
gripX: number;
gripY: number;
gripDirection: "left" | "right";
gripAbove: boolean;
stemTop: number;
stemHeight: number;
}> {
const { range, viewport, metrics } = geometry;
if (!range) return [];
return (["start", "end"] as const).flatMap((endpoint) => {
const cell = range[endpoint];
if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return [];
const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth;
const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight;
const size = TERMINAL_SELECTION_GRIP_SIZE;
const width = viewport.cols * metrics.cellWidth;
const height = viewport.rows * metrics.cellHeight;
const wantsLeft = endpoint === "start";
const gripDirection =
(wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right";
const gripAbove = stemTop + metrics.cellHeight + size > height;
const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight;
return [
{
endpoint,
x,
y: stemTop + metrics.cellHeight / 2,
gripX: x + (gripDirection === "left" ? -size / 2 : size / 2),
gripY: gripTop + size / 2,
gripDirection,
gripAbove,
stemTop,
stemHeight: metrics.cellHeight,
},
];
});
}
export function createTerminalSelectionController(ports: Ports) {
let press: {
start: TerminalSelectionPoint;
point: TerminalSelectionPoint;
endpoint: Endpoint | null;
offset: TerminalSelectionPoint;
fixed: TerminalBufferCoordinate | null;
moved: boolean;
long: boolean;
scrolling: boolean;
remainder: number;
} | null = null;
let timer: number | null = null;
function stopTimer() {
if (timer !== null) ports.cancel(timer);
timer = null;
}
function updateHandle() {
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const { viewport, metrics } = geometry;
const x = press.point.x - press.offset.x;
const y = press.point.y - press.offset.y;
let row =
viewport.firstRow +
Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight)));
let col = Math.floor(x / metrics.cellWidth);
if (press.endpoint && press.fixed) {
const boundary = Math.round(x / metrics.cellWidth);
const isEnd =
row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col);
col = boundary - (isEnd ? 1 : 0);
if (col < 0 && isEnd) {
row -= 1;
col = viewport.cols - 1;
} else if (col >= viewport.cols && !isEnd) {
row += 1;
col = 0;
}
}
col = Math.max(0, Math.min(viewport.cols - 1, col));
ports.onMoveHandle({ row, col });
}
function edgeTick() {
timer = null;
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const y = press.point.y - press.offset.y;
const height = geometry.viewport.rows * geometry.metrics.cellHeight;
let direction: "up" | "down" | null = null;
if (y < geometry.metrics.cellHeight) direction = "up";
else if (y > height - geometry.metrics.cellHeight) direction = "down";
if (!direction) return;
ports.onScroll(direction, 1);
if (!press) return;
updateHandle();
if (press) timer = ports.schedule(edgeTick, 100);
}
return {
press(point: TerminalSelectionPoint) {
stopTimer();
const geometry = ports.getGeometry();
const handles = geometry ? terminalSelectionHandles(geometry) : [];
const handle = handles
.map((item) => ({
endpoint: item.endpoint,
x: item.x,
y: item.y,
distance: Math.hypot(point.x - item.gripX, point.y - item.gripY),
}))
.filter((item) => item.distance <= 28)
.sort((a, b) => a.distance - b.distance)[0];
press = {
start: point,
point,
endpoint: handle?.endpoint ?? null,
offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 },
fixed:
handle && geometry?.range
? geometry.range[handle.endpoint === "start" ? "end" : "start"]
: null,
moved: false,
long: false,
scrolling: false,
remainder: 0,
};
if (handle && geometry) {
ports.onBeginHandle(handle.endpoint, geometry.viewport.cols);
return;
}
timer = ports.schedule(() => {
timer = null;
if (press && !press.moved) {
press.long = true;
ports.onLongPress(press.start);
}
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
move(point: TerminalSelectionPoint) {
if (!press) return;
const previous = press.point;
press.point = point;
const dx = point.x - press.start.x,
dy = point.y - press.start.y;
if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
press.moved = true;
if (!press.endpoint) stopTimer();
}
if (press.endpoint || press.long) {
if (press.endpoint || press.moved) {
updateHandle();
stopTimer();
if (press) timer = ports.schedule(edgeTick, 100);
}
} else if (!press.long) {
if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
press.scrolling = true;
press.remainder = dy;
} else if (press.scrolling) press.remainder += point.y - previous.y;
const height = ports.getGeometry()?.metrics.cellHeight ?? 0;
if (press.scrolling && height > 0) {
const rows = Math.trunc(press.remainder / height);
press.remainder -= rows * height;
if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows));
}
}
},
release() {
stopTimer();
if (press && !press.endpoint && !press.long && !press.moved) ports.onTap();
press = null;
},
cancel() {
stopTimer();
press = null;
},
};
}
@@ -1,354 +0,0 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const rowDeltas: number[] = [];
for (const currentDy of [13, 15, 18, 22, 28]) {
const update = advanceTerminalScrollGesture({
state,
currentDy,
cellHeight: 14,
});
state = update.state;
rowDeltas.push(update.rowDelta);
}
expect({ rowDeltas, state }).toEqual({
rowDeltas: [0, 1, 0, 0, 1],
state: { lastDy: 28, rowRemainder: 0, didScroll: true },
});
});
});
describe("classifyTerminalGestureIntent", () => {
it("treats a motionless press as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 0,
dy: 0,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("treats a tiny jitter within tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("leaves horizontal movement available to terminal selection", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("pending");
});
it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 1,
vx: 2,
vy: 0,
elapsedMs: 20,
}),
).toEqual("tap");
});
it("keeps horizontal movement below navigation distance pending", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10,
dy: 2,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("treats a clearly vertical drag as scroll", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 4,
dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5,
vx: 0,
vy: 0.2,
}),
).toEqual("scroll");
});
it("does not navigate on a fast rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a slow rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a fast leftward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate while movement stays inside tap tolerance", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 2,
vx: 2,
vy: 0,
}),
).toEqual("tap");
});
it("keeps selection intent once selection is active", () => {
expect(
classifyTerminalGestureIntent({
status: "selecting",
dx: 100,
dy: 50,
vx: 2,
vy: 1,
}),
).toEqual("select");
});
});
describe("resolveTerminalGestureReleaseAction", () => {
it("focuses the terminal after a normal tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("focuses the terminal after a normal tap while scrolled", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("focus");
});
it("starts selection after a long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("starts selection after a 500ms hold", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 500,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("keeps a 400ms press as a tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 400,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("does nothing after a scroll gesture", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: true,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("none");
});
it("does nothing after a navigation swipe", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: true,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing while actively selecting", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("clears an existing selection after a short tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: true,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("clear");
});
it("preserves a word selection created by the current long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does not focus after an undecided fast drag", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing from idle", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "idle",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
});
});
@@ -1,108 +0,0 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
export interface TerminalGestureMoveInput {
status: TerminalGestureStatus;
dx: number;
dy: number;
vx: number;
vy: number;
elapsedMs?: number;
}
export interface TerminalGestureReleaseInput {
status: TerminalGestureStatus;
startedWithSelection?: boolean;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export interface TerminalScrollGestureState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
}
export interface TerminalScrollGestureUpdate {
state: TerminalScrollGestureState;
rowDelta: number;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export function classifyTerminalGestureIntent(
input: TerminalGestureMoveInput,
): TerminalGestureIntent {
if (input.status === "selecting") {
return "select";
}
const absDx = Math.abs(input.dx);
const absDy = Math.abs(input.dy);
if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) {
return "scroll";
}
if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
return "pending";
}
return "tap";
}
export function advanceTerminalScrollGesture(input: {
state: TerminalScrollGestureState;
currentDy: number;
cellHeight: number;
}): TerminalScrollGestureUpdate {
const deltaY = input.currentDy - input.state.lastDy;
const accumulatedRows = input.state.rowRemainder + deltaY;
const rowDelta = Math.trunc(accumulatedRows / input.cellHeight);
return {
state: {
lastDy: input.currentDy,
rowRemainder: accumulatedRows - rowDelta * input.cellHeight,
didScroll: input.state.didScroll || rowDelta !== 0,
},
rowDelta,
};
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.didScroll || input.didNavigate) {
return "none";
}
if (input.status === "selecting") {
if (!input.startedWithSelection) {
return "none";
}
return input.movedBeyondTapTolerance ? "none" : "clear";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}
@@ -483,3 +483,51 @@ describe("native terminal selection", () => {
expect(copied).toEqual([]);
});
});
describe("endpoint dragging", () => {
it.each([
{
endpoint: "start" as const,
fixed: { row: 2, col: 9 },
target: { row: 3, col: 2 },
expected: { row: 3, col: 0 },
},
{
endpoint: "end" as const,
fixed: { row: 2, col: 0 },
target: { row: 1, col: 7 },
expected: { row: 1, col: 9 },
},
])(
"preserves the fixed boundary across a row edge for $endpoint",
({ endpoint, fixed, target, expected }) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: fixed, bounds });
selection.update({ coordinate: fixed, bounds });
selection.beginEndpointDrag(endpoint, 10);
selection.update({ coordinate: target, bounds });
expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual(
expected,
);
},
);
it.each(["start", "end"] as const)(
"keeps the opposite endpoint fixed as %s crosses it",
(endpoint) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: { row: 2, col: 2 }, bounds });
selection.update({ coordinate: { row: 3, col: 4 }, bounds });
selection.beginEndpointDrag(endpoint, 10);
const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 };
selection.update({ coordinate, bounds });
expect(selection.getSnapshot().range).toEqual(
endpoint === "start"
? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 }
: { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 },
);
},
);
});
@@ -25,6 +25,7 @@ export interface TerminalSelectionBounds {
export interface TerminalSelectionModel {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
beginEndpointDrag(endpoint: "start" | "end", cols: number): void;
clear(): TerminalSelectionSnapshot;
sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot;
getSnapshot(): TerminalSelectionSnapshot;
@@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects(
export function createTerminalSelectionModel(): TerminalSelectionModel {
let state: TerminalSelectionState = { anchor: null, range: null };
let drag: { endpoint: "start" | "end"; cols: number } | null = null;
function snapshot(): TerminalSelectionSnapshot {
return { range: state.range };
@@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
const coordinate = clampCoordinate(input.coordinate, input.bounds);
const anchor = state.anchor ?? coordinate;
const fixed = state.anchor ?? coordinate;
let anchor = fixed;
if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) {
anchor =
fixed.col + 1 < drag.cols
? { row: fixed.row, col: fixed.col + 1 }
: { row: fixed.row + 1, col: 0 };
} else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) {
anchor =
fixed.col > 0
? { row: fixed.row, col: fixed.col - 1 }
: { row: fixed.row - 1, col: drag.cols - 1 };
}
const range = normalizeTerminalSelection({
anchor,
focus: coordinate,
coordinateEpoch: input.bounds.coordinateEpoch,
});
state = { anchor, range };
state = { anchor: fixed, range };
return snapshot();
}
return {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
drag = null;
const coordinate = clampCoordinate(input.coordinate, input.bounds);
state = {
anchor: coordinate,
@@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
return setSelection(input);
},
beginEndpointDrag(endpoint, cols) {
if (state.range) {
drag = { endpoint, cols };
state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] };
}
},
clear(): TerminalSelectionSnapshot {
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
@@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
if (!state.range || rangeIsSafe(state.range, input.bounds)) {
return snapshot();
}
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
@@ -821,6 +821,67 @@ describe("incremental replay lifecycle", () => {
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }),
);
const terminal = window.__paseoTerminal;
if (!terminal) {
throw new Error("Expected the browser terminal to be mounted");
}
const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY);
const cells = Array.from({ length: 13 }, (_, col) => {
const cell = line?.getCell(col);
return { chars: cell?.getChars(), width: cell?.getWidth() };
});
expect(cells).toEqual([
{ chars: "A", width: 1 },
{ chars: "😀", width: 2 },
{ chars: "", width: 0 },
{ chars: "B", width: 1 },
{ chars: "❤️", width: 2 },
{ chars: "", width: 0 },
{ chars: "C", width: 1 },
{ chars: "👋🏽", width: 2 },
{ chars: "", width: 0 },
{ chars: "D", width: 1 },
{ chars: "👨‍👩‍👧", width: 2 },
{ chars: "", width: 0 },
{ chars: "E", width: 1 },
]);
expect(terminal.buffer.active.cursorX).toBe(13);
terminal.select(0, terminal.buffer.active.cursorY, 13);
expect(terminal.getSelection()).toBe(text);
});
// A host without grapheme widths answers cursor reports and replays with its
// own table. The web client keeps the Unicode 11 table it used before.
it("measures with the legacy table for hosts without grapheme widths", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const terminal = window.__paseoTerminal;
if (!terminal) {
throw new Error("Expected the browser terminal to be mounted");
}
const buffer = terminal.buffer;
async function cursorAfter(text: string): Promise<number> {
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }),
);
return buffer.active.cursorX;
}
mounted.runtime.setCharacterWidths({ widths: "legacy" });
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]);
mounted.runtime.setCharacterWidths({ widths: "graphemes" });
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]);
});
});
it("restores fitted web geometry after replay before live output", async () => {
@@ -36,6 +36,12 @@ vi.mock("@xterm/addon-unicode11", () => ({
},
}));
vi.mock("@xterm/addon-unicode-graphemes", () => ({
UnicodeGraphemesAddon: class UnicodeGraphemesAddon {
dispose(): void {}
},
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: class WebLinksAddon {
dispose(): void {}
@@ -82,6 +88,7 @@ import {
createTerminalResizeEvent,
encodeTerminalOutput,
TerminalEmulatorRuntime,
type TerminalClipboard,
} from "./terminal-emulator-runtime";
interface StubTerminal {
@@ -638,3 +645,97 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled();
});
});
describe("terminal-emulator-runtime clipboard shortcuts", () => {
type KeyHandler = (event: KeyboardEvent) => boolean;
function createClipboard(available: boolean) {
return {
isAvailable: () => available,
readText: vi.fn(async () => "clipboard text"),
read: vi.fn(async () => []),
writeText: vi.fn(async (_text: string) => undefined),
} satisfies TerminalClipboard;
}
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
let handler: KeyHandler | null = null;
const paste = vi.fn();
runtime.attachKeyEventHandler({
attachCustomKeyEventHandler: (next: KeyHandler) => {
handler = next;
},
hasSelection: () => input.selection.length > 0,
getSelection: () => input.selection,
paste,
input: vi.fn(),
scrollToBottom: vi.fn(),
});
if (!handler) throw new Error("key handler was not attached");
return { handler: handler as KeyHandler, paste };
}
function ctrlKey(key: string) {
const preventDefault = vi.fn();
const event = {
type: "keydown",
key,
isComposing: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
preventDefault,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
return { event, preventDefault };
}
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(false);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("v");
expect(handler(event)).toBe(false);
expect(clipboard.readText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
expect(paste).not.toHaveBeenCalled();
});
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(true);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("copies and pastes through the Clipboard API when it is available", async () => {
const clipboard = createClipboard(true);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
const copy = ctrlKey("c");
expect(handler(copy.event)).toBe(false);
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
expect(copy.preventDefault).not.toHaveBeenCalled();
const pasteKey = ctrlKey("v");
expect(handler(pasteKey.event)).toBe(false);
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
expect(clipboard.readText).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
});
});
@@ -3,10 +3,12 @@ import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalCharacterWidths } from "../character-widths";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
@@ -182,15 +184,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
}
export interface TerminalClipboard {
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
isAvailable?(): boolean;
readText(): Promise<string>;
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
writeText(text: string): Promise<void>;
}
function getBrowserClipboard(): Clipboard | undefined {
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
}
function rejectUnavailableClipboard(): Promise<never> {
return Promise.reject(new Error("Clipboard API is unavailable"));
}
const browserClipboard: TerminalClipboard = {
readText: () => navigator.clipboard.readText(),
read: () => navigator.clipboard.read(),
writeText: (text) => navigator.clipboard.writeText(text),
isAvailable: () => !!getBrowserClipboard(),
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
};
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
@@ -221,6 +234,12 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
return null;
}
// Grapheme widths match the daemon and native buffers. Legacy hosts keep the
// Unicode 11 table web used before. See docs/terminal-rendering.md.
function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void {
terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11";
}
export class TerminalEmulatorRuntime {
constructor(
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
@@ -233,6 +252,7 @@ export class TerminalEmulatorRuntime {
shift: false,
alt: false,
};
private characterWidths: TerminalCharacterWidths = "graphemes";
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
@@ -320,6 +340,16 @@ export class TerminalEmulatorRuntime {
this.pendingModifiers = input.pendingModifiers;
}
setCharacterWidths(input: { widths: TerminalCharacterWidths }): void {
this.characterWidths = input.widths;
const terminal = this.terminal;
if (!terminal) {
return;
}
applyCharacterWidths(terminal, input.widths);
this.refreshVisibleRows();
}
private loadSearchAddon(terminal: Terminal): void {
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
terminal.loadAddon(searchAddon);
@@ -368,22 +398,7 @@ export class TerminalEmulatorRuntime {
!event.altKey &&
!event.metaKey
) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void this.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
event.preventDefault();
this.pasteClipboard(terminal);
return false;
}
return true;
return !this.handleCtrlClipboardShortcut(terminal, event);
}
if (this.maybeSendMacEditingShortcut(terminal, event)) {
@@ -432,6 +447,36 @@ export class TerminalEmulatorRuntime {
});
}
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
private handleCtrlClipboardShortcut(
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
event: KeyboardEvent,
): boolean {
const key = event.key.toLowerCase();
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
// copy/paste events still fire and xterm handles them, so leave the default action.
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
if (clipboardAvailable) {
void this.clipboard.writeText(terminal.getSelection());
}
return true;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
if (clipboardAvailable) {
event.preventDefault();
this.pasteClipboard(terminal);
}
return true;
}
return false;
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
event.preventDefault();
@@ -529,11 +574,12 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
let imageAddon: ImageAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new Unicode11Addon());
terminal.loadAddon(new UnicodeGraphemesAddon());
applyCharacterWidths(terminal, this.characterWidths);
terminal.loadAddon(new WebLinksAddon(openExternalLink));
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
@@ -568,11 +614,6 @@ export class TerminalEmulatorRuntime {
terminal.open(input.host);
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
this.applyThemeBackground(input.theme);
try {
terminal.unicode.activeVersion = "11";
} catch {
// Ignore if unicode API isn't available in this build/runtime.
}
const disposeImageAddon = (): void => {
try {
+2
View File
@@ -3751,6 +3751,8 @@ export const ServerInfoStatusPayloadSchema = z
"terminal-input-mode-replay": z.boolean().optional(),
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
"terminal-size-ownership": z.boolean().optional(),
// COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01.
"terminal-grapheme-widths": z.boolean().optional(),
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: z.boolean().optional(),
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
+3 -2
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -117,7 +117,8 @@
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
@@ -1,4 +1,4 @@
import { mkdir } from "node:fs/promises";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) {
target: "es2022",
outfile: outputPath,
});
if (directory === "v2/") {
await copyFile(
new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url),
path.join(path.dirname(outputPath), "state.mjs"),
);
}
}
@@ -1220,6 +1220,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
}
});
test("a steer the provider names without echoing it can be rewound to", async () => {
// Claude takes a steered message into the running turn without echoing it back as a user
// message, so the id it minted for the message reaches the manager only through the admission.
class SilentSteeringSession extends SteeringTestSession {
override readonly capabilities = {
...TEST_CAPABILITIES,
supportsRewindFiles: true,
};
rewindMessageIds: string[] = [];
override async steerActiveTurn(
_prompt: AgentPromptInput,
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
): Promise<import("./agent-sdk-types.js").SteerResult> {
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
return { status: "unavailable" };
}
return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` };
}
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
this.rewindMessageIds.push(messageId);
}
}
const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const run = manager.streamAgent(agent.id, "initial");
void (async () => {
for await (const _event of run) {
}
})();
await manager.waitForAgentRunStart(agent.id);
await expect(
manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }),
).resolves.toMatchObject({ status: "accepted" });
const row = manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.find((candidate) => candidate.item.type === "user_message");
expect(row).toMatchObject({
providerMessageId: "provider-steer-client",
item: { text: "steered", clientMessageId: "steer-client" },
});
await manager.rewind(agent.id, "steer-client", "files");
expect(session.rewindMessageIds).toEqual(["provider-steer-client"]);
} finally {
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders a concurrent replacement after a pending accepted steer", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -3029,7 +3029,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
});
@@ -3062,7 +3068,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
})
@@ -3167,6 +3179,7 @@ export class AgentManager {
prompt: AgentPromptInput,
clientMessageId: string | undefined,
expectedTurnId: string,
providerMessageId: string | undefined,
): Promise<void> {
if (!clientMessageId) {
return;
@@ -3174,6 +3187,7 @@ export class AgentManager {
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
messageId: clientMessageId,
turnId: expectedTurnId,
...(providerMessageId ? { providerMessageId } : {}),
});
this.emitState(agent);
}
@@ -3505,6 +3519,10 @@ export class AgentManager {
"agent.rewind.complete",
);
} catch (error) {
// Native rewind can commit a fork before history refresh fails. Recovery
// must resume that latest handle rather than the abandoned conversation.
this.refreshSessionPersistence(agent);
await this.persistSnapshot(agent);
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
@@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions {
clearPendingPermissions?: boolean;
}
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
// providerMessageId names the steered message in the provider's own history, when the provider
// mints one. Rewinding to the message needs it.
export type SteerResult =
| { status: "accepted"; providerMessageId?: string }
| { status: "unavailable" };
export interface SteerActiveTurnOptions extends AgentSteerOptions {
expectedTurnId: string;
@@ -693,6 +697,7 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
/** Null until a new provider process could resume this session; see docs/providers.md. */
describePersistence(): AgentPersistenceHandle | null;
/**
* Resolve once every foreground turn that predates this call can no longer run or become active.
@@ -53,6 +53,7 @@ const CAPABILITIES = [
interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
closeAcknowledgementOnly?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
}
if (input.type === "session.close") {
emit({ type: "session.closed", sessionId: input.sessionId });
if (options.closeAcknowledgementOnly) return;
}
if ("requestId" in input) {
emit({ type: "request.completed", requestId: input.requestId });
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type);
}
function createRewindProviderHarness(
options: {
reuseAnswerId?: boolean;
forkOnRevert?: boolean;
handleInput?: ProviderHarnessOptions["handleInput"];
} = {},
) {
const nativeMessages: string[] = [];
let originalMessages: string[] | null = null;
const harness = createProviderHarness({
closeAcknowledgementOnly: true,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
async handleInput(input, emit) {
if (await options.handleInput?.(input, emit)) return true;
if (input.type === "session.open" && input.history === "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
restoration: "core",
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
const messages =
options.forkOnRevert &&
(input.persistence?.data as { token?: string })?.token === "root" &&
originalMessages
? originalMessages
: nativeMessages;
for (const [index, text] of messages.entries()) {
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
}
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (options.forkOnRevert && input.scope !== "files") {
originalMessages = [...nativeMessages];
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
}
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
const harness = createRewindProviderHarness(options);
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: true });
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
session.subscribe((event) => live.push(event));
await session.revertConversation!({ messageId: "user-B" });
live.length = 0;
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
async function withPluginRewindSession(
options: Parameters<typeof createRewindProviderHarness>[0],
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
try {
await session.run("A");
await session.run("B");
await session.run("C");
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
const events = await Array.fromAsync(session.streamHistory());
return events.flatMap((event) =>
event.type === "timeline" &&
(event.item.type === "user_message" || event.item.type === "assistant_message")
? [event.item.text]
: [],
);
}
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
await session.revertConversation!({ messageId: "user-A" });
expect(await pluginMessages(session)).toEqual([]);
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type === "session.revert")
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
return false;
},
},
async (session, harness) => {
await session.revertConversation!({ messageId: "user-B" });
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
persistence: { version: 1, data: { token: "fork" } },
history: "replay",
});
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
},
);
});
test("failed native plugin rewind preserves history and does not reopen", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "native rewind failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"native rewind failed",
);
expect(await pluginMessages(session)).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
]);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
},
);
});
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
let failReplay = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"replay failed",
);
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
await session.revertConversation!({ messageId: "user-B" });
await session.run("D");
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
handleInput: async (input, emit) => {
if (input.type === "session.close") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("closing during plugin rewind replay closes each native binding once", async () => {
let opened!: () => void;
let release!: () => void;
const replayStarted = new Promise<void>((resolve) => {
opened = resolve;
});
const replayAllowed = new Promise<void>((resolve) => {
release = resolve;
});
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type === "session.open" && input.history === "replay") {
opened();
await replayAllowed;
}
return false;
},
},
async (session, harness) => {
const rewind = session.revertConversation!({ messageId: "user-B" });
await replayStarted;
const closing = session.close();
release();
await Promise.all([rewind, closing]);
const closes = harness.inputs.flatMap((input) =>
input.type === "session.close" ? [input.sessionId] : [],
);
expect(closes).toHaveLength(2);
expect(new Set(closes).size).toBe(2);
},
);
});
test("plugin rewind retries a failed close before reopening the native writer", async () => {
let failClose = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.close" || !failClose) return false;
failClose = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "close failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"close failed",
);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("plugin rewind retries permission settlement before retiring its session", async () => {
let failPermission = true;
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type !== "session.permission" || !failPermission) return false;
failPermission = false;
throw new Error("permission delivery failed");
},
},
async (session, harness) => {
const opened = harness.inputs.find((input) => input.type === "session.open")!;
if (opened.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: opened.sessionId,
request: { id: "pending", name: "tool", kind: "tool" },
});
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"permission delivery failed",
);
expect(session.getPendingPermissions()).toHaveLength(1);
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
},
);
});
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
await withPluginRewindSession({}, async (session, harness) => {
const root = harness.inputs.find((input) => input.type === "session.open")!;
if (root.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: root.sessionId,
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.revertConversation!({ messageId: "user-B" });
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.permission",
permissionId: "old-permission",
response: { behavior: "deny", message: "Session replaced after rewind" },
}),
);
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
});
const history = await Array.fromAsync(session.streamHistory());
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
harness.emit({
type: "session.opened",
sessionId: "child-1",
parentSessionId: reopened.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
});
const next = await Array.fromAsync(session.streamHistory());
expect(next).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ text: "New child reply" }),
}),
}),
);
});
});
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
restoration: "core",
cwd: input.config.cwd,
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
expect(session.capabilities.supportsRewindConversation).toBe(false);
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"requires session persistence",
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
await session.revertFiles!({ messageId: "user-B" });
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
},
);
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [
["a", rootSessionId],
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
>();
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
}
this.closePromise ??= this.closeNativeSession().catch((error) => {
this.closePromise = null;
throw error;
});
await this.closePromise;
}
private async closeNativeSession(): Promise<void> {
try {
await this.runtime.complete({
type: "session.close",
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
// A failed close still needs its routing for a subsequent session.closed ack.
this.runtime.removeSession(this.id, this.providerSessionId);
}
beginOpen(requestId: string): Promise<void> {
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
persistence: input.persistence,
history: input.history,
});
const session = new PluginAgentSession(this.provider, bridge, () => {
this.rootsBySession.delete(bridge.id);
});
this.rootsBySession.set(bridge.id, session);
this.attachPendingChildren();
const forget = () => {
for (const [id, owner] of this.rootsBySession) {
if (owner === session) this.rootsBySession.delete(id);
}
};
const bind = (next: ProviderRuntimeSession) => {
this.rootsBySession.set(next.id, session);
this.attachPendingChildren();
};
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
const session = new PluginAgentSession(
this.provider,
bridge,
forget,
async (persistence, state) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
...config,
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
},
persistence,
history: "replay",
});
},
bind,
);
bind(bridge);
return session;
}
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly children = new Map<
string,
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
private replay: {
persistence: ProviderPersistence;
config: ProviderConfigState;
messageId: string;
scope: "conversation" | "both";
closed: boolean;
} | null = null;
private replaying: Promise<void> | null = null;
constructor(
readonly provider: string,
private readonly bridge: ProviderRuntimeSession,
private bridge: ProviderRuntimeSession,
private readonly onClose: () => void,
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
) {
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
}
get capabilities(): AgentCapabilityFlags {
return agentCapabilities(this.bridge.negotiatedCapabilities);
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
const resumable = this.canReplayHistory;
return {
...capabilities,
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
};
}
get features(): AgentFeature[] {
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: AgentRunOptions = {},
): Promise<{ turnId: string }> {
this.assertReplayComplete();
const clientMessageId = options.clientMessageId ?? randomUUID();
const result = await this.bridge.prompt({
clientMessageId,
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
this.assertReplayComplete();
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" };
}
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureReplay();
for (const event of this.history.read(this.provider)) yield event;
}
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ mode: modeId });
}
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.unsubscribe?.();
this.unsubscribe = null;
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear();
await this.replaying?.catch(() => undefined);
await this.clearBinding();
this.replay = null;
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession {
}
async setModel(modelId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
@@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession {
const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set(
child.id,
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
);
this.children.set(child.id, {
session: child,
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
});
}
private accept(event: ProviderEvent, live: boolean): void {
@@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession {
}
}
private async clearBinding(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
for (const child of this.children.values()) {
child.unsubscribe();
await child.session.close();
}
this.children.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.currentTurnId = null;
}
private assertReplayComplete(): void {
if (this.replay) {
throw new Error(
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
);
}
}
// A native rewind has already committed. Until replay succeeds, neither history
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
private async ensureReplay(): Promise<void> {
if (!this.replay) return;
if (this.closed) throw new StaleProviderSessionError(this.id);
if (!this.replaying) {
this.replaying = this.replayBinding().finally(() => {
this.replaying = null;
});
}
await this.replaying;
}
private async replayBinding(): Promise<void> {
const replay = this.replay!;
if (!replay.closed) {
for (const request of this.pendingPermissions.values()) {
await this.respondToPermission(request.id, {
behavior: "deny",
message: "Session replaced after rewind",
});
this.pendingPermissions.delete(request.id);
this.permissionResponses.delete(request.id);
}
await this.clearBinding();
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
this.onBind(bridge);
this.replay = null;
}
private get canReplayHistory(): boolean {
return (
this.bridge.restoration === "core" &&
this.bridge.persistence !== null &&
this.bridge.negotiatedCapabilities.includes("session.persistence")
);
}
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
const pending = this.replay;
await this.ensureReplay();
if (pending?.messageId === messageId && pending.scope === scope) return;
if (scope !== "files" && !this.canReplayHistory) {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
await this.bridge.revert({
@@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession {
token,
scope,
});
if (scope !== "files") {
const persistence = this.bridge.persistence;
if (!persistence) throw new Error("Plugin rewind removed session persistence");
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
await this.ensureReplay();
}
}
private publish(event: AgentStreamEvent): void {
@@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions {
export async function createAgentProviderRuntime(
options: CreateAgentProviderRuntimeOptions,
): Promise<AgentProviderRuntime> {
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger });
const configuredPort =
options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT;
const port = configuredPort === undefined ? undefined : Number(configuredPort);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535");
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port });
try {
await bridge.start();
const snapshotManager = new ProviderSnapshotManager({
@@ -1,6 +1,6 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -2634,6 +2634,72 @@ describe("ACPAgentSession slash commands", () => {
},
]);
});
test("lists late command batches through the public ACP client", async () => {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-"));
const script = path.join(directory, "agent.mjs");
await writeFile(
script,
`
import { createInterface } from "node:readline";
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
const help = { name: "help", description: "Show available commands" };
const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } };
const publish = (commands) => send({
jsonrpc: "2.0", method: "session/update", params: {
sessionId: "inventory-session", update: {
sessionUpdate: "available_commands_update", availableCommands: commands,
},
},
});
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.method === "initialize") {
send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } });
} else if (request.method === "session/new") {
// The first batch is already cached when createSession returns.
publish([help]);
send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } });
setTimeout(() => publish([help]), 100);
setTimeout(() => publish([help, skill]), 350);
} else if (request.id !== undefined) {
send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } });
}
});
`,
);
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, script],
});
try {
const session = await client.createSession({ provider: "acp", cwd: directory });
try {
const commands = await session.listCommands!();
expect(commands).toEqual([
{
name: "help",
description: "Show available commands",
argumentHint: "",
kind: "command",
},
{
name: "xlsx",
description: "Work with spreadsheets",
argumentHint: "[instructions]",
kind: "command",
},
]);
expect(await session.listCommands!()).toEqual(commands);
} finally {
await session.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("ACPAgentSession", () => {
@@ -120,6 +120,7 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -142,29 +143,29 @@ function assertChildWithPipes(
}
}
// Node reports a failed spawn (missing binary or missing cwd) as a child
// `error` event; without a listener it becomes an uncaught exception.
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
// Watch process failures as well as spawn errors: the SDK leaves initialize
// pending when the child exits without replying. `close` waits for stderr to
// drain, so the failure includes the launcher's diagnostic.
function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise<never> {
return new Promise<never>((_, reject) => {
child.once("error", (error) => {
const stderr = stderrChunks.join("").trim();
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
child.once("close", (code, signal) => {
const stderr = stderrChunks.join("").trim();
const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(
new Error(
`ACP agent process exited before initialization completed (${status})${
stderr ? `\n${stderr}` : ""
}`,
),
);
});
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -325,6 +326,14 @@ const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000;
const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000;
const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000;
/**
* An ACP agent publishes its slash-command inventory incrementally. Wait this
* long without another batch before treating the inventory as complete. MiniMax
* publishes its skills about 350ms after its built-in commands.
*/
const COMMAND_INVENTORY_QUIET_MS = 600;
const COMMAND_INVENTORY_POLL_MS = 25;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
return {
type: error instanceof Error ? error.name : typeof error,
@@ -542,7 +551,7 @@ interface ACPProcessTransport {
connection: ClientSideConnection;
stderrChunks: string[];
spawnReady: Promise<void>;
spawnError: Promise<never>;
processError: Promise<never>;
}
interface ACPImportPromptPreviews {
@@ -1380,6 +1389,7 @@ export class ACPAgentClient implements AgentClient {
client?: ACPClient;
},
): Promise<SpawnedACPProcess> {
const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {});
const transport = await this.spawnTransport(launchEnv, options?.client);
const probe: UninitializedACPProcess = {
child: transport.child,
@@ -1391,7 +1401,7 @@ export class ACPAgentClient implements AgentClient {
const initialize = await this.initializeTransport(
transport,
options?.initializeTimeoutMs,
options?.providerOptions,
configuredOptions.clientCapabilities,
);
const initializedProbe: SpawnedACPProcess = {
...probe,
@@ -1425,7 +1435,7 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const processErrorPromise = rejectOnProcessFailure(child, stderrChunks);
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -1444,16 +1454,15 @@ export class ACPAgentClient implements AgentClient {
connection,
stderrChunks,
spawnReady: spawnReadyPromise,
spawnError: spawnErrorPromise,
processError: processErrorPromise,
};
}
protected async initializeTransport(
transport: ACPProcessTransport,
initializeTimeoutMs?: number,
providerOptions?: Record<string, unknown>,
clientCapabilities?: ACPClientCapabilities,
): Promise<InitializeResponse> {
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => {
@@ -1470,11 +1479,11 @@ export class ACPAgentClient implements AgentClient {
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
configuredOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilities ?? this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
transport.spawnError,
transport.processError,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
@@ -1555,7 +1564,7 @@ export class ACPAgentClient implements AgentClient {
try {
transport = await this.spawnTransport(PROBE_ENV);
await withTimeout(
Promise.race([transport.spawnReady, transport.spawnError]),
Promise.race([transport.spawnReady, transport.processError]),
phaseTimeoutMs,
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
);
@@ -1753,7 +1762,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
@@ -2051,58 +2061,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
];
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
}
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
this.commandsReadyDeferred = { promise, resolve };
}
private settleCommandsReady(): void {
if (this.commandsReadySettled) {
return;
}
this.commandsReadySettled = true;
this.commandsReadyDeferred?.resolve();
this.commandsReadyDeferred = null;
}
private async waitForCommandsReady(): Promise<void> {
const deferred = this.commandsReadyDeferred;
if (!deferred) {
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
deferred.promise,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
/**
* Providers publish their command inventory incrementally. MiniMax sends its
* built-in commands twice, then a third batch about 350ms later that merges in
* every discovered skill. Returning after the first batch loses the skills.
*
* Wait for the inventory to go quiet rather than for the first batch. The
* quiet window must exceed the gap between a provider's own batches; 600ms
* covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the
* whole wait for providers that publish once or never publish at all.
*/
private async waitForCommandsToSettle(): Promise<void> {
const deadline = Date.now() + this.initialCommandsWaitTimeoutMs;
while (!this.commandsReadySettled && !this.closed) {
const remaining = deadline - Date.now();
if (remaining <= 0) return;
const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt;
if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return;
await new Promise<void>((resolve) =>
setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)),
);
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.cachedCommands.length > 0) {
return this.cachedCommands;
}
if (!this.waitForInitialCommands || this.closed) {
if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) {
return this.cachedCommands;
}
this.ensureCommandsReadyDeferred();
await this.waitForCommandsReady();
await this.waitForCommandsToSettle();
this.settleCommandsReady();
return this.cachedCommands;
}
@@ -2733,14 +2724,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
// Cache an asynchronously-delivered slash-command batch and unblock any
// listCommands() call that is waiting on the initial batch. Used when a
// provider supplies an extensionCommandsParser whose result arrives after
// session/new (e.g. via a vendor extension notification). The ready gate is
// always settled — even for an empty batch — so a provider that legitimately
// reports no commands does not leave listCommands() blocked for the full
// initial-commands timeout. An optional sessionId scopes the batch to this
// session; notifications addressed to a different session are ignored.
// Cache an asynchronously-delivered slash-command batch from a provider that
// supplies an extensionCommandsParser (e.g. a vendor extension notification).
// An empty batch is the provider saying it has no commands, so it settles the
// waiter immediately instead of letting it wait out the quiet window. An
// optional sessionId scopes the batch to this session; notifications
// addressed to a different session are ignored.
private applyResolvedCommands(
commands: AgentSlashCommand[],
options?: { sessionId?: string },
@@ -2754,7 +2743,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
if (commands.length > 0) {
this.commandsUpdatedAt = Date.now();
this.cachedCommands = commands;
// A non-empty batch may be followed by more. Let the quiet window decide
// when the inventory is complete rather than settling here; an empty
// batch is the provider saying it has nothing, which must still unblock
// the waiter immediately.
return;
}
this.settleCommandsReady();
}
@@ -2877,11 +2872,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -2910,7 +2902,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(chunk.toString());
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
const processError = rejectOnProcessFailure(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed || this.historyRuntimeReleased) {
return;
@@ -2947,7 +2939,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
processError,
]),
);
@@ -3138,13 +3130,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.handleUsageUpdate(update);
return pendingUserEvents;
case "available_commands_update":
this.commandsUpdatedAt = Date.now();
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
description: command.description,
argumentHint: command.input?.hint ?? "",
kind: "command",
}));
this.settleCommandsReady();
// An empty batch means the agent has no commands and would otherwise
// wait out the whole quiet window. A non-empty batch may be one of
// several, so completion belongs to the quiet window.
if (update.availableCommands.length === 0) {
this.settleCommandsReady();
}
return pendingUserEvents;
default:
return pendingUserEvents;
@@ -0,0 +1,112 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
// Exercise the public catalog API with a real child, including stdout EOF and
// stderr draining. No provider installation or external service is involved.
const failingCommand: [string, ...string[]] = [
process.execPath,
"-e",
"process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });",
];
describe("ACP catalog startup failures", () => {
test("reports a process that exits during initialize with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(
client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }),
).rejects.toThrow(/launcher failed: fixture startup error/);
}, 5_000);
test("session startup reports the same process exit with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow(
/launcher failed: fixture startup error/,
);
}, 5_000);
test("provider diagnostics report the startup exit instead of an initialize timeout", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
diagnosticPhaseTimeoutMs: 2_000,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain(
"ACP agent process exited before initialization completed (exit code 1)",
);
expect(diagnostic).toContain("launcher failed: fixture startup error");
expect(diagnostic).not.toContain("initialize timed out");
});
test("invalid provider options reject without crashing the host process", async () => {
const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href;
const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href;
const script = `
import { GenericACPAgentClient } from ${JSON.stringify(clientModule)};
import { createTestLogger } from ${JSON.stringify(loggerModule)};
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'],
});
try {
await client.fetchCatalog({
scope: 'workspace', cwd: process.cwd(), force: true,
providerOptions: { clientCapabilities: { fs: 'invalid' } },
});
throw new Error('Expected invalid options to reject');
} catch (error) {
if (error.name !== 'ZodError') throw error;
}
// Let child cleanup and Node's unhandled-rejection check complete.
await new Promise(resolve => setTimeout(resolve, 100));
console.log('host stayed alive');
`;
const result = await promisify(execFile)(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{ timeout: 10_000 },
);
expect(result.stdout).toContain("host stayed alive");
expect(result.stderr).toBe("");
});
test("a successful catalog probe still returns its models and closes cleanly", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [
process.execPath,
"-e",
`
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
const request = JSON.parse(line);
const result = request.method === 'initialize'
? { protocolVersion: 1, agentCapabilities: {} }
: { sessionId: 'fixture', models: {
currentModelId: 'fixture-model',
availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }]
} };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
});
`,
],
});
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: process.cwd(),
force: true,
});
expect(catalog.models).toEqual([
expect.objectContaining({ id: "fixture-model", label: "Fixture model" }),
]);
});
});
@@ -614,6 +614,43 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("replays a message steered into a running turn as the user's message", async () => {
const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({
type: "attachment",
uuid: `attachment-${commandMode}`,
parentUuid: "a1",
attachment: {
type: "queued_command",
commandMode,
prompt,
...(sourceUuid ? { source_uuid: sourceUuid } : {}),
},
});
const entries = [
{ type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } },
{ type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } },
queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"),
queued("task-notification", "<task-notification><task-id>t1</task-id></task-notification>"),
{ type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const session = await resume();
try {
const users = [];
for await (const event of session.streamHistory()) {
if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item);
}
expect(users).toEqual([
{ type: "user_message", text: "first", messageId: "u1" },
{ type: "user_message", text: "steered while working", messageId: "steered-1" },
]);
} finally {
await session.close();
}
});
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
@@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{
expectedTurnId: start.value.turnId,
clientMessageId: "steer-client",
});
expect(steered).toEqual({ status: "accepted" });
await waitFor(() => query?.prompts.length === 2);
expect(steered).toEqual({
status: "accepted",
providerMessageId: query?.prompts[1]?.uuid,
});
return { session, query: () => query, turn };
}
@@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it
const steeredTimeline = await autonomousEvents.next();
const completion = await autonomousEvents.next();
expect(steer).toEqual({ status: "accepted" });
expect(steer).toEqual({
status: "accepted",
providerMessageId: queryRef?.prompts[1]?.uuid,
});
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryRef?.interrupt).not.toHaveBeenCalled();
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]);
@@ -1,7 +1,11 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDir } from "./project-dir.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js";
@@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
});
describe("Claude rewind to a message steered into a running turn", () => {
test("forks at the entry Claude read the message after", async () => {
const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
const conversation = createConversation([{ assistantMessageId: "assistant-1" }]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 1);
// A steered message opens no turn: Claude Code records it as a queued_command attachment
// under the uuid Paseo minted for it, hung off the entry it was read after.
const projectDir = await claudeProjectDir(process.cwd(), { configDir });
mkdirSync(projectDir, { recursive: true });
const entries = [
{ type: "user", uuid: conversation.userMessageIds[0], parentUuid: null },
{ type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] },
{ type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" },
{
type: "attachment",
uuid: "queued-command-1",
parentUuid: "tool-result-1",
attachment: { type: "queued_command", source_uuid: "steered-message-1" },
},
];
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""),
);
await session.revertConversation?.({ messageId: "steered-message-1" });
} finally {
await session.close();
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
rmSync(configDir, { recursive: true, force: true });
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]);
});
});

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