Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andBae Seongmin 2291f54dc5 fix(app): paste copied text and its image together
Pasting from Word put only an image of the text in the composer, because
the paste handler cancelled the paste whenever the clipboard held an
image. Let the text paste normally when the clipboard has text, and still
attach the images, so content copied with both (Word, or the desktop
browser's element screenshot) keeps both.

Fixes #3851

Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-10-05 11:15:59 +02:00
Bae Seongmin 5eaf6d5464 Merge branch 'main' into fix/paste-text-with-image-fallback 2026-09-29 23:25:10 +09:00
paseo-bot[bot] 2838922ade fix(opencode): show the question text and typed answer in v2 question cards (#5679)
OpenCode v2 sends each question as a form field whose title is the short
header, whose description is the question, and whose custom flag allows a
typed answer. The mapper used the field key as the header and the title as
the question, and dropped custom, so the card showed only the header and
had no answer field.

Closes #5667

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 16:00:31 +02:00
+1 264f6e1cca feat(omp): make OMP a first-class provider (#5550)
Makes OMP a first-class provider: import keeps the session model, replay matches live rendering, /compact waits for OMP, Auto thinking, working multi-select and Other answers, mid-turn steering, fallback model and Fast mode, archived agents open without starting omp, profile-aware import, crash recovery and approval-mode changes, readable tool-call rows, one bubble per /skill, blocked and abandoned todos, OMP notifications, hidden todo reminders on reload, and session MCP servers for schedules and Hub runs.

Fixes #2727, fixes #2857, fixes #4000, fixes #4790, fixes #5354, fixes #5099

Co-authored-by: Abhishek Gupta <183943363+abhi-wan-kenobi@users.noreply.github.com>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Kh05ifr4nD <meandSSH0219@gmail.com>
Co-authored-by: Nguyen Quang Minh <135627235+n24q02m@users.noreply.github.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: allo <alloevil@hotmail.com>
Co-authored-by: gray-graff <gray.graff@gmail.com>
Co-authored-by: kasrakhosravi <kasra@goldsky.com>
Co-authored-by: viet-initx <viet@initx.io>
2026-09-29 14:12:53 +01:00
Mohamed Boudra cc744cc669 feat(app): let users set the chat and Markdown content width (#5680)
* feat(app): let users set the chat and Markdown content width

Wide and ultrawide screens were stuck with an 820px reading column. The
width is now an Appearance setting that flows through the Unistyles theme,
like the font sizes, so every content surface picks it up.

The stored value stays null until the user picks a width, so a future
change to the default reaches everyone who never customized it. Reset
writes null back.

The native composer dock keeps plain React Native styles and takes the width
inline because those styles land on Reanimated views, where theme-driven
Unistyles crash on theme changes.

* fix(app): pass the content width into the web stream viewport

The viewport read app settings itself, which required a QueryClient in every
render of the stream strategy. The agent stream view already reads settings,
so it hands the width over with the rest of the render input.
2026-09-29 13:49:03 +01:00
paseo-bot[bot]and3ae3ae aff476b01b fix(codex): insert custom prompt arguments literally (#5669)
Custom prompt expansion replaced each placeholder kind in its own pass,
so text inserted by an earlier pass was expanded again ($ARGUMENTS
containing "$1"), and named values went through String.replace, which
reads "$&" and friends as replacement patterns. Match every placeholder
in one pass and insert values from a callback.

Closes #5613

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-29 13:51:43 +02:00
Mohamed Boudra d596861891 feat(sidebar): make empty project actions clear and quiet (#5675)
Use the shared button sizes and a bordered list card so the empty sidebar gives project setup actions a consistent visual hierarchy.
2026-09-29 12:02:38 +01:00
Mohamed Boudra be2fb1cab9 fix(opencode): keep long v2 turns alive until execution completes (#5674)
The long-lived session wait hit Node’s headers timeout while OpenCode kept working. Reconcile execution events against active state and the durable log so transport observation failures do not fail healthy turns, including across event gaps and reconnects.
2026-09-29 11:56:57 +01:00
paseo-bot[bot] 87818812ad Stop the desktop app from launching a process every second when no local daemon runs (#5636)
* fix(desktop): stop launching the CLI every second when no local daemon runs

The app polls the local daemon status once a second until it learns a
server id, and a stopped daemon has none, so the poll never ends. Each
poll ran `paseo daemon status` in a new process, which on Windows is
Paseo.exe and shows the busy cursor about once a second while the app
sits idle. The status handler now answers "stopped" in-process when the
home has no running daemon, which is what the CLI reports in that case.

* fix(desktop): report an errored daemon when its state file cannot be read

Reading the daemon instance record ran before the try in
resolveDesktopDaemonStatus, so an unreadable paseo.pid made the status
IPC reject instead of returning the errored status the CLI failure path
produced.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 11:19:19 +02:00
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
3ae3ae 6dc17b2f1b Merge main into fix/paste-text-with-image-fallback 2026-09-29 16:05:21 +09:00
3ae3ae 052974a308 fix(app): preserve text when the clipboard also contains an image 2026-09-29 15:43:16 +09:00
paseo-bot[bot] d91171954b fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 08:01:59 +02:00
paseo-bot[bot]andDaye f232d23e9a fix(client): let SDK callers choose what a send does to an active turn (#5605)
PaseoAgentSendOptions was a hand-copied subset of the daemon client's
SendMessageOptions and dropped activeTurnBehavior, so
agents.ref(id).send(text, { activeTurnBehavior: "steer" }) and run() failed
to typecheck even though the value is forwarded to the daemon. The public
type is now the options type send() passes through, so the two cannot
drift again.

Closes #4555

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Daye <2077152374@qq.com>
2026-09-29 06:53:14 +02:00
paseo-bot[bot] 68e722ec2e fix(app): request a chat outline only for a timeline the daemon has served (#5602)
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 06:16:30 +02:00
paseo-bot[bot] 3b41183606 fix(app): start the context meter ring at twelve o'clock on web (#5598)
The ring's quarter-turn lived in a Unistyles style on the react-native-svg
root. On web, Unistyles only turns styles into classes for the React Native
components its Babel plugin rewrites, so the Svg received an empty style and
the arc started at three o'clock. The rotation now lives on the progress
circle as an SVG transform, which renders the same on web and native.

Closes #5596

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 04:57:36 +02:00
paseo-bot[bot]andIvan Malison 690f5298cb fix(app): keep draft composers off the agent command list before a project and model are chosen (#5594)
A draft composer passed no command config when it was missing a project
or a provider, which is what a running agent's composer passes. The `/`
menu then listed commands by the draft key, got "Agent not found", and
showed /clear and /exit. Drafts now always report a command target
(ready, needs-project, needs-provider), and an incomplete draft asks for
the missing choice instead of querying.

Closes #5516

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-29 04:08:31 +02:00
paseo-bot[bot] 68938b668e fix(skills): make the paseo-plugin composer pill example match the plugin SDK (#5591)
The example passed title, Component, and onPress straight to
addComposerPill and treated its return value as a remover function, so it
failed to typecheck against @getpaseo/plugin. It also followed agents with
agents.subscribe, a local listener that never delivers agents that exist
before the plugin loads. The example now uses the button descriptor and an
owned list subscription, like the plugin reference and local-plugin
example, and a test typechecks it against the SDK.

Closes #5517

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 02:51:54 +02:00
paseo-bot[bot] 560e478ef5 Wait for the connection before starting a file upload (#5585)
An upload started while the client was reconnecting, such as right after
returning from the Android file picker, queued its request but sent the
file frames at once. The frames were dropped and the upload failed with
"Connection changed during file upload". The upload now waits on the
same connection queue as other requests before it sends anything.

Closes #5512

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 01:05:46 +02:00
Mohamed Boudra 4965af219f docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 940dbfd242 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.
2026-09-28 22:32:35 +01:00
Qi 4340607093 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.
2026-09-28 22:15:24 +01:00
paseo-bot[bot] 849a876bce Keep completed plugin-provider sessions error-free across a plugin reload (#5579)
Removing a plugin provider closed its runtime and published a runtime
failure for every open session, so an idle session whose turn had already
completed gained a "Provider connection closed" error. Only a turn that
is still in flight is interrupted now; an idle session goes stale and its
next prompt reopens it from persistence.

Closes #5535

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:55:07 +02:00
paseo-bot[bot] f4ba16a0b3 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:09:16 +02:00
140 changed files with 6360 additions and 1050 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## 0.10.1 - 2026-09-29
### Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
### Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
## 0.10.0 - 2026-09-28
### Added
+2 -2
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
### Cancellation
+1 -1
View File
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
+2
View File
@@ -99,6 +99,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
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.
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.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

@@ -17,12 +17,14 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -18,6 +18,7 @@ import {
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
pasteClipboardIntoComposer,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
@@ -53,6 +54,43 @@ const TEST_JSON = {
};
test.describe("Composer attachments", () => {
test("pasting copied document text keeps the text and attaches its image", async ({
page,
context,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "paste-document-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
const text = "Copied paragraph with 한글.\nSecond line.";
await pasteClipboardIntoComposer(page, context, {
text,
html: "<p>Copied paragraph with 한글.</p><p>Second line.</p>",
png: MINIMAL_PNG,
});
await expectComposerDraft(page, text);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("pasting an image without text attaches it and leaves the draft empty", async ({
page,
context,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "paste-image-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await pasteClipboardIntoComposer(page, context, { png: MINIMAL_PNG });
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expectComposerDraft(page, "");
});
test("selected file shows a loading attachment until upload is acknowledged", async ({
page,
withWorkspace,
@@ -10,6 +10,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
{
@@ -338,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
).toBeUndefined();
}
function recordCommandRequestsByAgentId(page: Page): string[] {
const requests: string[] = [];
const record = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
if (!payload.includes("draftConfig")) requests.push(payload);
};
page.on("websocket", (socket) => socket.on("framesent", record));
return requests;
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
@@ -359,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
await page.goto("/new");
await expectNewWorkspaceProjectSelected(page, "Choose project");
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ visible: true })
.first();
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
await expect(popover).not.toContainText("/clear");
await expect(popover).not.toContainText("/exit");
expect(commandRequestsByAgentId).toEqual([]);
});
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
@@ -0,0 +1,70 @@
import { test } from "../support/fixtures";
import {
expectContentColumnWidth,
expectCustomContentWidthSetting,
expectDefaultContentWidthSetting,
openAppearanceSettings,
resetContentWidth,
setContentWidth,
} from "../support/helpers/content-width";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "content-width-",
title: "Content width",
initialPrompt: "Write a long paragraph.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
const assistantMessage = page.getByTestId("assistant-message").first();
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
await test.step("chat starts at the default width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
});
await test.step("settings show the default without a reset", async () => {
await openAppearanceSettings(page);
await expectDefaultContentWidthSetting(page, 820);
});
await test.step("a custom width is saved and offers a reset", async () => {
await setContentWidth(page, 1600);
await expectCustomContentWidthSetting(page, 1600);
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
});
await test.step("chat and Markdown files use the custom width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
});
await test.step("reset returns to the default without storing it", async () => {
await openAppearanceSettings(page);
await resetContentWidth(page);
await expectDefaultContentWidthSetting(page, 820);
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
});
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,71 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
const ring = meter.locator("svg");
const progressColor = await ring
.locator("circle")
.last()
.evaluate((circle) => getComputedStyle(circle).stroke);
const screenshot = await ring.screenshot();
return ring.evaluate(
async (_svg, { png, color }) => {
const image = await createImageBitmap(
await (await fetch(`data:image/png;base64,${png}`)).blob(),
);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
const [r, g, b] = color.match(/\d+/g)!.map(Number);
let sumX = 0;
let sumY = 0;
let count = 0;
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const i = (y * image.width + x) * 4;
const distance =
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
if (distance < 40) {
sumX += x;
sumY += y;
count += 1;
}
}
}
if (count === 0) {
throw new Error(`No pixels painted in the progress colour ${color}`);
}
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
},
{ png: screenshot.toString("base64"), color: progressColor },
);
}
test.describe("context window meter", () => {
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
test.setTimeout(180_000);
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-meter-",
title: "Context window meter e2e",
initialPrompt: "emit 32000 byte file agent stream payload",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,78 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
import { submitMessage } from "../support/helpers/composer";
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
test.use({
e2eDaemonConfig: {
version: 1,
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
},
});
async function selectMedium(page: Page) {
await page
.getByRole("button", { name: /^Select thinking option/ })
.filter({ visible: true })
.click();
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
await expect(
page
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
.filter({ visible: true }),
).toBeVisible();
}
async function selectLongcat(page: Page) {
await page
.getByRole("button", { name: /^Select model \(/ })
.filter({ visible: true })
.click();
await page
.getByText("LongCat 2.5 Preview Free", { exact: true })
.filter({ visible: true })
.click();
}
async function askForReply(handle: AgentHandle) {
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
return handle.client.waitForFinish(handle.agentId, 120_000);
}
test("switching an existing OpenCode session clears unsupported thinking", async ({
page,
}, testInfo) => {
test.setTimeout(240_000);
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
cwd,
provider: "opencode",
mode: "full-access",
providerConfig: { model: "opencode/space-bunny-free" },
});
await selectMedium(page);
expect((await askForReply(handle)).status).toBe("idle");
await selectLongcat(page);
await expect(
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
).toHaveCount(0);
const finish = await askForReply(handle);
const screenshot = testInfo.outputPath("model-switch.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("After switching to Longcat", {
path: screenshot,
contentType: "image/png",
});
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeFalsy();
await expect(
page.getByTestId("assistant-message").filter({ visible: true }).last(),
).toContainText("OK");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -14,6 +14,7 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { loadSessionMessageReaders } from "./new-workspace";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
@@ -229,3 +230,22 @@ async function requireBoundingBox(
}
return box;
}
export async function observePromptIndexRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const agentIds: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "agent.timeline.list_prompts.request") {
agentIds.push(request.agentId);
}
});
});
return {
async waitForRequestFor(agentId: string) {
await expect.poll(() => agentIds).toContain(agentId);
},
requestedAgentIds: () => [...new Set(agentIds)],
};
}
+25 -1
View File
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type BrowserContext, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
@@ -172,6 +172,30 @@ export async function dropFileOnComposer(
await dataTransfer.dispose();
}
/** Puts the given formats on the system clipboard together, then pastes them into the composer with the keyboard shortcut. */
export async function pasteClipboardIntoComposer(
page: Page,
context: BrowserContext,
clipboard: { text?: string; html?: string; png?: Buffer },
): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ text, html, pngBase64 }) => {
const formats: Record<string, Blob> = {};
if (text !== undefined) formats["text/plain"] = new Blob([text], { type: "text/plain" });
if (html !== undefined) formats["text/html"] = new Blob([html], { type: "text/html" });
if (pngBase64 !== undefined) {
const bytes = Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0));
formats["image/png"] = new Blob([bytes], { type: "image/png" });
}
await navigator.clipboard.write([new ClipboardItem(formats)]);
},
{ text: clipboard.text, html: clipboard.html, pngBase64: clipboard.png?.toString("base64") },
);
await composerInput(page).click();
await page.keyboard.press("ControlOrMeta+v");
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
@@ -0,0 +1,63 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function contentWidthInput(page: Page): Locator {
return page.getByLabel("Content width in pixels", { exact: true });
}
function resetContentWidthButton(page: Page): Locator {
return page.getByRole("button", { name: "Reset content width to default", exact: true });
}
export async function openAppearanceSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
}
export async function setContentWidth(page: Page, width: number): Promise<void> {
const input = contentWidthInput(page);
await input.fill(String(width));
await input.press("Tab");
}
export async function resetContentWidth(page: Page): Promise<void> {
await resetContentWidthButton(page).click();
}
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toHaveCount(0);
await expect.poll(() => readStoredContentWidth(page)).toBeNull();
}
export async function expectCustomContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toBeVisible();
await expect.poll(() => readStoredContentWidth(page)).toBe(width);
}
/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
export async function expectContentColumnWidth(
element: Locator,
bounds: { min?: number; max: number },
): Promise<void> {
await expect(element).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeLessThanOrEqual(bounds.max);
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeGreaterThan(bounds.min ?? 0);
}
async function readStoredContentWidth(page: Page): Promise<unknown> {
return page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw === null
? undefined
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
}, APP_SETTINGS_KEY);
}
@@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) {
)
.toBe(count);
},
async agentIds() {
await requests.settled();
return (await project.client.fetchAgents()).entries
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => {
it("accepts only the authoritative timeline epoch", () => {
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
});
});
@@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
export function shouldAcceptPromptIndexEpoch(
timelineEpoch: string | null,
indexEpoch: string,
): boolean {
return timelineEpoch === null || timelineEpoch === indexEpoch;
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
return timelineEpoch === indexEpoch;
}
/**
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
runtime.subscribeAgentTimeline.mockClear();
});
it("waits for a served timeline before requesting its prompt index", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
const viewportRef = createRef<StreamViewportHandle>();
const { rerender } = renderHook(
({ agentId, timelineEpoch }) =>
useChatOutline({
agentId,
serverId: "server-1",
timelineEpoch,
tail: [],
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
}),
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
);
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
});
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
const first = deferred<{ epoch: string; prompts: [] }>();
const second = deferred<{
@@ -77,8 +77,10 @@ export function useChatOutline({
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
// epoch, and its id names no agent the daemon knows.
useEffect(() => {
if (!isWeb || !enabled) {
if (!isWeb || !enabled || timelineEpoch === null) {
setIndex(null);
return;
}
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
it("keeps a line break when a chunk ends in the next line's indent", () => {
const harness = streamHarness();
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
harness.send(assistant(" "));
const result = harness.send(assistant(" B --> C[Finish]\n```"));
expect(result.head).toMatchObject([
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
]);
});
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
})}
</>,
);
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const unrevised = {
contentById: new Set<string>(),
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
@@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
return row ? estimateStreamItemHeight(row) : 120;
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
},
measureElement: measureVirtualElement,
rangeExtractor,
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
}
export interface ResolveStreamRenderStrategyInput {
@@ -1,7 +1,6 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { SPACING, type Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
@@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) {
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
+6 -4
View File
@@ -23,7 +23,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
@@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
contentWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
@@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => {
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
@@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => {
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
});
it("uses cached assistant image metadata when available", () => {
@@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => {
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
});
});
@@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow {
mountedEntries: IndexedStreamItem[];
}
export function estimateStreamItemHeight(item: StreamItem): number {
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
@@ -37,6 +37,7 @@ interface FakeTheme {
"4xl": number;
};
lineHeight: { diff: number };
contentMaxWidth: number;
colors: { foreground: string; syntax: Record<string, string> };
}
@@ -56,6 +57,7 @@ function makeFakeTheme(): FakeTheme {
"4xl": 26,
},
lineHeight: { diff: 22 },
contentMaxWidth: 820,
colors: { foreground: "#fff", syntax: {} },
};
}
@@ -67,6 +69,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
uiBaseFontSize: 14,
contentFontSize: 15,
codeFontSize: 12,
contentMaxWidth: 820,
syntaxTheme: "one",
...overrides,
};
@@ -102,6 +105,12 @@ describe("applyAppearance", () => {
]);
});
it("patches the content max width into the theme", () => {
applyAppearance(makeInput({ contentMaxWidth: 1600 }));
expect(runCapturedUpdater().contentMaxWidth).toBe(1600);
});
it("resolves an empty UI font family to the default stack", () => {
applyAppearance(makeInput({ uiFontFamily: "" }));
+4 -1
View File
@@ -17,6 +17,7 @@ export interface AppearanceInput {
uiBaseFontSize: number; // already clamped
contentFontSize: number; // already clamped
codeFontSize: number; // already clamped
contentMaxWidth: number; // already clamped, default resolved
syntaxTheme: SyntaxThemeId;
}
@@ -55,7 +56,7 @@ function scaleFontSize(
* always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
*
* The updater preserves the active theme wholesale (surfaces, accents,
* terminal) and only patches the font ramp and syntax palette.
* terminal) and only patches the font ramp, content width, and syntax palette.
* `updateTheme` replaces the stored theme rather than merging, so we spread
* `...t` first.
*/
@@ -86,6 +87,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
}
@@ -94,6 +96,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
});
+8 -1
View File
@@ -8,7 +8,12 @@ import {
useState,
} from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import {
DEFAULT_THEME_PREFERENCE,
resolveContentMaxWidth,
useAppSettings,
type AppSettings,
} from "@/hooks/use-settings";
import {
rememberPluginThemeHost,
usePluginThemeCatalog,
@@ -68,6 +73,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
uiBaseFontSize: settings.uiBaseFontSize,
contentFontSize: settings.contentFontSize,
codeFontSize: settings.codeFontSize,
contentMaxWidth: resolveContentMaxWidth({ contentMaxWidth: settings.contentMaxWidth }),
syntaxTheme: settings.syntaxTheme,
});
setHasAppliedAppearance(true);
@@ -80,6 +86,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
settings.uiBaseFontSize,
settings.contentFontSize,
settings.codeFontSize,
settings.contentMaxWidth,
settings.syntaxTheme,
]);
@@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
},
callout: {
flexDirection: "row",
@@ -117,7 +117,6 @@ export function ContextWindowMeter({
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -158,7 +157,6 @@ export function ContextWindowMeter({
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -180,6 +178,8 @@ export function ContextWindowMeter({
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
{showPercentage ? (
@@ -226,9 +226,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
svg: {
transform: [{ rotate: "-90deg" }],
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { type ReactNode, useCallback } from "react";
import { Import, Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -6,6 +6,28 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
function SidebarEmptyStateCard({
testID,
title,
description,
children,
}: {
testID: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<View style={styles.card} testID={testID}>
<View style={styles.copy}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
<View style={styles.actions}>{children}</View>
</View>
);
}
/**
* The two things the sidebar's workspace list says when it has no rows.
*
@@ -27,13 +49,15 @@ export function SidebarFilterEmptyState() {
}, [clearLabelFilter, clearProjectFilters]);
return (
<View style={styles.container} testID="sidebar-filter-empty-state">
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
<Button variant="ghost" size="sm" onPress={clearFilters}>
<SidebarEmptyStateCard
testID="sidebar-filter-empty-state"
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
{t("sidebar.filterEmpty.clear")}
</Button>
</View>
</SidebarEmptyStateCard>
);
}
@@ -47,40 +71,44 @@ export function SidebarProjectEmptyState({
const { t } = useTranslation();
return (
<View style={styles.container} testID="sidebar-project-empty-state">
<Text style={styles.title}>{t("sidebar.project.empty.title")}</Text>
<Text style={styles.description}>{t("sidebar.project.empty.description")}</Text>
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
<SidebarEmptyStateCard
testID="sidebar-project-empty-state"
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
{t("importSession.title")}
</Button>
</View>
</SidebarEmptyStateCard>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
card: {
marginTop: theme.spacing[3],
padding: theme.spacing[4],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
copy: {
gap: theme.spacing[1],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
fontWeight: theme.fontWeight.normal,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
@@ -2,7 +2,8 @@ import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
@@ -89,6 +90,7 @@ export function ComposerDock({
centered = false,
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
@@ -98,7 +100,7 @@ export function ComposerDock({
bottomInset={bottomInset}
centered
>
<KeyboardTranslateView style={dockStyles.centered}>
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{content}
@@ -137,13 +139,15 @@ export function ComposerDock({
);
}
// Plain React Native styles: these land on Reanimated views, which must not carry
// theme-driven Unistyles (docs/unistyles.md). The content width arrives inline.
const dockStyles = StyleSheet.create({
viewport: { flex: 1, overflow: "hidden" },
surface: { flex: 1 },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
centered: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
centered: { flexShrink: 1, width: "100%" },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
+6 -5
View File
@@ -1,8 +1,9 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import type { ReactNode } from "react";
import { View, StyleSheet } from "react-native";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
@@ -35,7 +36,7 @@ export function ComposerDock({
);
}
const styles = StyleSheet.create({
const styles = StyleSheet.create((theme) => ({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
@@ -45,7 +46,7 @@ const styles = StyleSheet.create({
justifyContent: "center",
paddingBottom: HEADER_INNER_HEIGHT + 24,
},
form: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
form: { flexShrink: 1, width: "100%", maxWidth: theme.contentMaxWidth },
centeredComposer: { flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
});
}));
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
expect(getLatest().composerState?.commandDraft).toEqual({
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
const hydratedTextReplacement = getLatest().textReplacement;
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "@/provider-selection/provider-selection";
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
describe("buildDraftCommandTarget", () => {
const codexSelection = {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
};
it("needs a project when cwd is empty", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: " ",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
).toEqual({ status: "needs-project" });
});
it("needs a provider when none is selected", () => {
expect(
buildDraftCommandTarget({
selection: { ...codexSelection, provider: null },
cwd: "/repo",
effectiveModelId: "",
effectiveThinkingOptionId: "",
}),
).toEqual({ status: "needs-provider" });
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: "/repo",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
});
});
+14 -18
View File
@@ -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 { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
@@ -16,7 +16,7 @@ import {
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
commandDraft: DraftCommandTarget;
};
export interface AgentInputDraft {
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[applyProfileFeatureValues, formState],
);
const commandDraftConfig = useMemo(
const commandDraft = useMemo(
() =>
composerOptions
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
})
: undefined,
buildDraftCommandTarget({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
}),
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraftConfig,
commandDraft,
};
}, [
commandDraftConfig,
commandDraft,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
@@ -358,7 +355,6 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftCommandTarget,
buildDraftAgentControls,
};
@@ -40,11 +40,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
buildWorkspaceTabPersistenceKey,
@@ -682,7 +678,7 @@ export function WorkspaceDraftAgentTab({
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
@@ -737,7 +733,7 @@ const styles = StyleSheet.create((theme) => ({
},
importPillContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
},
errorContainer: {
+8 -8
View File
@@ -41,7 +41,7 @@ import {
Paperclip,
} from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { FOOTER_HEIGHT } from "@/constants/layout";
import {
AgentControls,
DraftAgentControls,
@@ -62,7 +62,7 @@ import {
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
@@ -973,8 +973,8 @@ interface ComposerProps {
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
commandDraft?: DraftCommandTarget;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void;
onComposerHeightChange?: (height: number) => void;
@@ -1264,7 +1264,7 @@ function ComposerContentImpl({
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
commandDraft,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
@@ -2346,7 +2346,7 @@ function ComposerContentImpl({
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
draft: commandDraft,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
@@ -2355,7 +2355,7 @@ function ComposerContentImpl({
replaceUserInput,
serverId,
agentId,
commandDraftConfig,
commandDraft,
buildOutgoingAttachments,
attachments,
runClientSlashCommand,
@@ -2542,7 +2542,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputAreaContent: {
flexShrink: 1,
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
gap: theme.spacing[3],
},
messageInputContainer: {
+3 -1
View File
@@ -475,7 +475,9 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) return;
event.preventDefault();
// Copied text often carries an image of itself, as from Word. Let the
// text paste normally and attach the images alongside it.
if (!event.clipboardData?.getData("text/plain")) event.preventDefault();
void filesToImageAttachments(imageFiles)
.then((pastedAttachments) => {
+1 -2
View File
@@ -11,7 +11,6 @@ import {
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
@@ -328,7 +327,7 @@ const styles = StyleSheet.create((theme) => {
},
track: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
-2
View File
@@ -11,8 +11,6 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Settings uses the canonical desktop list + detail layout. Its sidebar and
@@ -2,7 +2,6 @@ import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { parseMarkdownPreviewDocument } from "./document";
export function FileMarkdownPreview({ source }: { source: string }) {
@@ -10,7 +9,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
return (
<View style={styles.outerGutter}>
<View style={styles.readingFrame}>
<View style={styles.readingFrame} testID="markdown-preview-frame">
{document.frontMatter.length > 0 ? (
<View style={styles.frontMatterTable} testID="markdown-front-matter">
{document.frontMatter.map((row, index) => (
@@ -49,7 +48,7 @@ const styles = StyleSheet.create((theme) => ({
},
readingFrame: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -7,6 +7,7 @@ import {
useAgentCommandsQuery,
type AgentSlashCommand,
type DraftCommandConfig,
type DraftCommandTarget,
} from "./use-agent-commands-query";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
@@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput {
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
draft?: DraftCommandTarget;
onAutocompleteApplied?: () => void;
onClientSlashCommand?: (command: ClientSlashCommand) => void;
canExecuteClientSlashCommand?: boolean;
@@ -117,18 +118,13 @@ type AvailableCommand =
| { source: "plugin"; command: PluginClientSlashCommand }
| { source: "provider"; command: AgentSlashCommand };
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig,
): DraftCommandConfig | undefined {
if (!draftConfig) {
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
if (draft?.status !== "ready") {
return undefined;
}
const draftConfig = draft.config;
const cwd = draftConfig.cwd.trim();
if (!cwd) {
return undefined;
}
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
@@ -337,6 +333,35 @@ function resolveAutocompleteErrorMessage(args: {
return undefined;
}
// A draft's agentId is a draft key the daemon does not know, so a draft lists
// commands by its config only, and not at all until that config is complete.
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
const queryDraftConfig = resolveDraftQueryConfig(draft);
const isDraftContext = draft !== undefined;
return {
isDraftContext,
queryDraftConfig,
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
};
}
function resolveAutocompleteEmptyText(args: {
mode: AutocompleteMode;
draft: DraftCommandTarget | undefined;
t: TFunction;
}): string {
if (args.mode === "file") {
return args.t("agentAutocomplete.noFiles");
}
if (args.draft?.status === "needs-project") {
return args.t("agentAutocomplete.chooseProjectForCommands");
}
if (args.draft?.status === "needs-provider") {
return args.t("agentAutocomplete.chooseModelForCommands");
}
return args.t("agentAutocomplete.noCommands");
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const { t } = useTranslation();
const {
@@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
setUserInput,
serverId,
agentId,
draftConfig,
draft,
onAutocompleteApplied,
onClientSlashCommand,
canExecuteClientSlashCommand,
@@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return () => clearTimeout(timer);
}, [fileFilterQuery]);
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig],
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
() => resolveDraftCommandContext(draft),
[draft],
);
const isDraftContext = normalizedDraftConfig !== undefined;
const queryDraftConfig = normalizedDraftConfig;
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
const agentCwd = useSessionStore(
@@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
autocompleteCwd,
});
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
const commandsQuery = useAgentCommandsQuery({
serverId,
agentId,
enabled: mode === "command" && canLoadCommands,
enabled: mode === "command" && canLoadCommands && canListCommands,
draftConfig: queryDraftConfig,
});
const { commands, isError, error } = commandsQuery;
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
@@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
mode === "file"
? t("agentAutocomplete.searchingWorkspace")
: t("agentAutocomplete.loadingCommands");
const emptyText =
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
return {
isVisible,
@@ -17,6 +17,12 @@ export interface AgentSlashCommand {
export type DraftCommandConfig = AgentCommandsDraftConfig;
/** What a draft composer can list commands for, before an agent exists. */
export type DraftCommandTarget =
| { status: "ready"; config: DraftCommandConfig }
| { status: "needs-project" }
| { status: "needs-provider" };
interface ListAgentCommandsOptions {
agentId: string;
draftConfig?: DraftCommandConfig;
@@ -17,22 +17,27 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_MAX_WIDTH,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_CONTENT_MAX_WIDTH,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_CONTENT_MAX_WIDTH,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
normalizeAppSettings,
parseClampedFontSize,
parseContentMaxWidth,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
@@ -56,19 +61,24 @@ export {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_MAX_WIDTH,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_CONTENT_MAX_WIDTH,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_CONTENT_MAX_WIDTH,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
parseContentMaxWidth,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
};
export type {
@@ -14,6 +14,7 @@ import {
loadSettingsFromStorage,
parseClampedFontSize,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -22,7 +23,7 @@ import {
DEFAULT_SIDEBAR_ROW_ITEMS,
SIDEBAR_ROW_ITEMS,
} from "@/components/sidebar/display-preferences/row-items";
import { THEME_OPTIONS } from "@/styles/theme";
import { DEFAULT_CONTENT_MAX_WIDTH, THEME_OPTIONS } from "@/styles/theme";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -955,3 +956,49 @@ describe("parseClampedFontSize", () => {
expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull();
});
});
describe("content max width", () => {
it("follows the current default until the user picks a width", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
await saveAppSettings({ queryClient, updates: { codeFontSize: 14 }, deps });
const stored = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
expect(stored.contentMaxWidth).toBeNull();
expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(
DEFAULT_CONTENT_MAX_WIDTH,
);
});
it("keeps a picked width and returns to the default when reset", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
await saveAppSettings({ queryClient, updates: { contentMaxWidth: 1600 }, deps });
expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(1600);
await saveAppSettings({ queryClient, updates: { contentMaxWidth: null }, deps });
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null").contentMaxWidth).toBe(
null,
);
expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(
DEFAULT_CONTENT_MAX_WIDTH,
);
});
it("clamps stored widths and drops malformed ones to the default", async () => {
const load = (contentMaxWidth: unknown) =>
loadAppSettingsFromStorage(
makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ contentMaxWidth }),
}),
}),
);
expect((await load(100_000)).contentMaxWidth).toBe(4000);
expect((await load(10)).contentMaxWidth).toBe(600);
expect((await load("wide")).contentMaxWidth).toBeNull();
});
});
@@ -15,6 +15,7 @@ import {
} from "@/components/sidebar/display-preferences/row-items";
import { isNative } from "@/constants/platform";
import {
DEFAULT_CONTENT_MAX_WIDTH,
FONT_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
@@ -63,6 +64,9 @@ export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
export const MIN_CODE_FONT_SIZE = 9;
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
export const MAX_FONT_FAMILY_LENGTH = 200;
export { DEFAULT_CONTENT_MAX_WIDTH };
export const MIN_CONTENT_MAX_WIDTH = 600;
export const MAX_CONTENT_MAX_WIDTH = 4000;
export interface AppSettings {
theme: ThemePreference;
@@ -78,6 +82,8 @@ export interface AppSettings {
uiBaseFontSize: number; // clamped px, platform default 14 or 15
contentFontSize: number; // clamped px, platform default 15 or 16
codeFontSize: number; // clamped px, default 12
/** Max width of chat and markdown content in px; null follows the current default. */
contentMaxWidth: number | null;
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
@@ -132,6 +138,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
contentFontSize: DEFAULT_CONTENT_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
contentMaxWidth: null,
syntaxTheme: "one",
workspaceTitleSource: "title",
sidebarWorkspaceTrailing: "diff",
@@ -218,6 +225,10 @@ const StoredAppSettingsSchema = z
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
DEFAULT_CODE_FONT_SIZE,
),
contentMaxWidth: z
.null()
.or(clampedNumber(MIN_CONTENT_MAX_WIDTH, MAX_CONTENT_MAX_WIDTH))
.catch(null),
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
@@ -457,6 +468,14 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
);
}
export function parseContentMaxWidth(value: unknown): number | null {
return parseClampedFontSize(value, { min: MIN_CONTENT_MAX_WIDTH, max: MAX_CONTENT_MAX_WIDTH });
}
export function resolveContentMaxWidth(settings: Pick<AppSettings, "contentMaxWidth">): number {
return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH;
}
export function parseClampedFontSize(
value: unknown,
bounds: { min: number; max: number },
+10
View File
@@ -1522,6 +1522,8 @@ export const ar: TranslationResources = {
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
loadOlderHistory: {
failed: "تعذر تحميل السجل الأقدم",
@@ -2189,6 +2191,14 @@ export const ar: TranslationResources = {
codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية",
codeSizeAccessibility: "حجم خط الكود",
},
layout: {
title: "التخطيط",
contentWidth: "عرض المحتوى",
contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة",
contentWidthAccessibility: "عرض المحتوى بالبكسل",
reset: "إعادة تعيين",
resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي",
},
syntax: {
title: "بناء الجملة",
highlightTheme: "تسليط الضوء على الموضوع",
+11
View File
@@ -1547,6 +1547,8 @@ export const en = {
noFiles: "No files or directories found",
noCommands: "No commands found",
failedToLoad: "Failed to load",
chooseProjectForCommands: "Choose a project to see commands",
chooseModelForCommands: "Select a model to see commands",
},
loadOlderHistory: {
failed: "Couldn't load older history",
@@ -2069,6 +2071,7 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
@@ -2312,6 +2315,14 @@ export const en = {
codeSizeHint: "Used for code, diffs, and terminal output",
codeSizeAccessibility: "Code font size",
},
layout: {
title: "Layout",
contentWidth: "Content width",
contentWidthHint: "Max width of chat and Markdown files on wide screens",
contentWidthAccessibility: "Content width in pixels",
reset: "Reset",
resetAccessibility: "Reset content width to default",
},
syntax: {
title: "Syntax",
highlightTheme: "Highlight theme",
+10
View File
@@ -1568,6 +1568,8 @@ export const es: TranslationResources = {
noFiles: "No se encontraron archivos ni directorios",
noCommands: "No se encontraron comandos",
failedToLoad: "No se pudo cargar",
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
},
loadOlderHistory: {
failed: "No se pudo cargar el historial anterior",
@@ -2243,6 +2245,14 @@ export const es: TranslationResources = {
codeSizeHint: "Se usa en código, diferencias y la salida del terminal",
codeSizeAccessibility: "Tamaño de fuente del código",
},
layout: {
title: "Diseño",
contentWidth: "Ancho del contenido",
contentWidthHint: "Ancho máximo del chat y de los archivos Markdown en pantallas anchas",
contentWidthAccessibility: "Ancho del contenido en píxeles",
reset: "Restablecer",
resetAccessibility: "Restablecer el ancho del contenido",
},
syntax: {
title: "Sintaxis",
highlightTheme: "Tema destacado",
+10
View File
@@ -1572,6 +1572,8 @@ export const fr: TranslationResources = {
noFiles: "Aucun fichier ou répertoire trouvé",
noCommands: "Aucune commande trouvée",
failedToLoad: "Échec du chargement",
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
},
loadOlderHistory: {
failed: "Impossible de charger l'ancien historique",
@@ -2246,6 +2248,14 @@ export const fr: TranslationResources = {
codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal",
codeSizeAccessibility: "Taille de la police du code",
},
layout: {
title: "Mise en page",
contentWidth: "Largeur du contenu",
contentWidthHint: "Largeur maximale du chat et des fichiers Markdown sur les grands écrans",
contentWidthAccessibility: "Largeur du contenu en pixels",
reset: "Réinitialiser",
resetAccessibility: "Réinitialiser la largeur du contenu",
},
syntax: {
title: "Syntaxe",
highlightTheme: "Thème de surbrillance",
+10
View File
@@ -1539,6 +1539,8 @@ export const ja: TranslationResources = {
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
loadOlderHistory: {
failed: "古い履歴を読み込めませんでした",
@@ -2209,6 +2211,14 @@ export const ja: TranslationResources = {
codeSizeHint: "コード、差分、ターミナル出力に使用されます",
codeSizeAccessibility: "コードフォントサイズ",
},
layout: {
title: "レイアウト",
contentWidth: "コンテンツ幅",
contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅",
contentWidthAccessibility: "コンテンツ幅 (ピクセル)",
reset: "リセット",
resetAccessibility: "コンテンツ幅をデフォルトに戻す",
},
syntax: {
title: "構文ハイライト",
highlightTheme: "ハイライトテーマ",
+10
View File
@@ -1532,6 +1532,8 @@ export const ko: TranslationResources = {
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
loadOlderHistory: {
failed: "이전 기록을 불러올 수 없습니다",
@@ -2200,6 +2202,14 @@ export const ko: TranslationResources = {
codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다",
codeSizeAccessibility: "코드 글꼴 크기",
},
layout: {
title: "레이아웃",
contentWidth: "콘텐츠 너비",
contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비",
contentWidthAccessibility: "콘텐츠 너비(픽셀)",
reset: "재설정",
resetAccessibility: "콘텐츠 너비를 기본값으로 재설정",
},
syntax: {
title: "구문",
highlightTheme: "강조 테마",
@@ -18,6 +18,7 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -72,6 +73,7 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
@@ -126,6 +128,7 @@ export const pluginSettings = {
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
@@ -180,6 +183,7 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -234,6 +238,7 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -287,6 +292,7 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.",
backToPlugins: "Voltar para plugins",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
@@ -341,6 +347,7 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -395,6 +402,7 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
+10
View File
@@ -1553,6 +1553,8 @@ export const ptBR: TranslationResources = {
noFiles: "Nenhum arquivo ou diretório encontrado",
noCommands: "Nenhum comando encontrado",
failedToLoad: "Falha ao carregar",
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
chooseModelForCommands: "Selecione um modelo para ver os comandos",
},
loadOlderHistory: {
failed: "Não foi possível carregar o histórico mais antigo",
@@ -2224,6 +2226,14 @@ export const ptBR: TranslationResources = {
codeSizeHint: "Usado em código, diffs e saída do terminal",
codeSizeAccessibility: "Tamanho da fonte de código",
},
layout: {
title: "Layout",
contentWidth: "Largura do conteúdo",
contentWidthHint: "Largura máxima do chat e dos arquivos Markdown em telas largas",
contentWidthAccessibility: "Largura do conteúdo em pixels",
reset: "Redefinir",
resetAccessibility: "Redefinir a largura do conteúdo",
},
syntax: {
title: "Sintaxe",
highlightTheme: "Tema de destaque",
+10
View File
@@ -1551,6 +1551,8 @@ export const ru: TranslationResources = {
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
loadOlderHistory: {
failed: "Не удалось загрузить старую историю.",
@@ -2229,6 +2231,14 @@ export const ru: TranslationResources = {
codeSizeHint: "Используется для кода, диффов и вывода терминала",
codeSizeAccessibility: "Размер шрифта кода",
},
layout: {
title: "Макет",
contentWidth: "Ширина содержимого",
contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах",
contentWidthAccessibility: "Ширина содержимого в пикселях",
reset: "Сбросить",
resetAccessibility: "Сбросить ширину содержимого",
},
syntax: {
title: "Синтаксис",
highlightTheme: "Тема подсветки",
+10
View File
@@ -1505,6 +1505,8 @@ export const zhCN: TranslationResources = {
noFiles: "没有找到文件或目录",
noCommands: "没有找到 commands",
failedToLoad: "加载失败",
chooseProjectForCommands: "选择项目以查看命令",
chooseModelForCommands: "选择模型以查看命令",
},
loadOlderHistory: {
failed: "无法加载更早历史",
@@ -2164,6 +2166,14 @@ export const zhCN: TranslationResources = {
codeSizeHint: "用于代码、差异和终端输出",
codeSizeAccessibility: "代码字号",
},
layout: {
title: "布局",
contentWidth: "内容宽度",
contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度",
contentWidthAccessibility: "内容宽度(像素)",
reset: "重置",
resetAccessibility: "将内容宽度重置为默认值",
},
syntax: {
title: "语法",
highlightTheme: "高亮主题",
+2 -6
View File
@@ -38,11 +38,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
@@ -1750,7 +1746,7 @@ const styles = StyleSheet.create((theme) => ({
},
timelineSyncCalloutContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
},
timelineSyncCallout: {
flexDirection: "row",
+27 -3
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -127,7 +128,30 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
export function PluginSettingsContent({
onBackToPlugins,
showBackToPlugins,
...identity
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
const { t } = useTranslation();
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -5,7 +5,7 @@ import type {
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog";
@@ -286,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId(
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftCommandConfig(input: {
export function buildDraftCommandTarget(input: {
selection: ProviderSelectionState;
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
}): DraftCommandTarget {
const cwd = input.cwd.trim();
if (!input.selection.provider || !cwd) {
return undefined;
if (!cwd) {
return { status: "needs-project" };
}
if (!input.selection.provider) {
return { status: "needs-provider" };
}
return {
const config = {
provider: input.selection.provider,
cwd,
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
@@ -310,6 +313,7 @@ export function buildDraftCommandConfig(input: {
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
return { status: "ready", config };
}
export function resolveSubmissionReadiness(input: {
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
/>
);
@@ -1333,6 +1333,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/>
);
if (view.kind === "host") {
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -18,7 +18,11 @@ import {
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useContributedThemes } from "@/appearance/provider";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { Button } from "@/components/ui/button";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import {
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
@@ -27,6 +31,8 @@ import {
MIN_CONTENT_FONT_SIZE,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
parseContentMaxWidth,
resolveContentMaxWidth,
sanitizeFontFamily,
useAppSettings,
type AppSettings,
@@ -326,6 +332,74 @@ function FontSizeRow({
);
}
// ---------------------------------------------------------------------------
// Content width: numeric field (commit on blur/submit) + reset to the default
// ---------------------------------------------------------------------------
interface ContentWidthRowProps {
value: AppSettings["contentMaxWidth"];
onChange: (value: AppSettings["contentMaxWidth"]) => void;
}
function ContentWidthRow({ value, onChange }: ContentWidthRowProps) {
const { t } = useTranslation();
const width = resolveContentMaxWidth({ contentMaxWidth: value });
// The field is uncontrolled, so a saved or reset width is written into it directly.
const input = useRef<EditingTextInputHandle>(null);
useEffect(() => {
input.current?.replaceText(String(width));
}, [width]);
const commit = useCallback(() => {
const next = parseContentMaxWidth(input.current?.getText()) ?? width;
input.current?.replaceText(String(next));
// Typing the width already in effect keeps following the default.
if (next !== width) {
onChange(next);
}
}, [onChange, width]);
const reset = useCallback(() => {
onChange(null);
}, [onChange]);
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.layout.contentWidth")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.appearance.layout.contentWidthHint")}
</Text>
</View>
<View style={styles.sizeField}>
{value === null ? null : (
<Button
variant="ghost"
size="sm"
onPress={reset}
accessibilityLabel={t("settings.appearance.layout.resetAccessibility")}
>
{t("settings.appearance.layout.reset")}
</Button>
)}
<TextInput
ref={input}
initialValue={String(width)}
onBlur={commit}
onSubmitEditing={commit}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.widthInput}
accessibilityLabel={t("settings.appearance.layout.contentWidthAccessibility")}
/>
<Text style={styles.unit}>px</Text>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Syntax highlight theme picker (commits immediately)
// ---------------------------------------------------------------------------
@@ -440,6 +514,13 @@ export function AppearanceSection() {
[selectPluginTheme],
);
const handleContentMaxWidthChange = useCallback(
(contentMaxWidth: AppSettings["contentMaxWidth"]) => {
void updateSettings({ contentMaxWidth });
},
[updateSettings],
);
const handleSyntaxThemeChange = useCallback(
(syntaxTheme: SyntaxThemeId) => {
void updateSettings({ syntaxTheme });
@@ -603,6 +684,14 @@ export function AppearanceSection() {
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.layout.title")}>
<View style={settingsStyles.card}>
<ContentWidthRow
value={settings.contentMaxWidth}
onChange={handleContentMaxWidthChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.syntax.title")}>
<View style={settingsStyles.card}>
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
@@ -668,6 +757,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "right",
},
widthInput: {
width: 80,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
textAlign: "right",
},
unit: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
+6 -1
View File
@@ -642,7 +642,10 @@ export const DEFAULT_MONO_FONT_STACK: string = Platform.select({
web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
});
// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain
// Chat and markdown content column; the appearance updater patches the user's width in.
export const DEFAULT_CONTENT_MAX_WIDTH = 820;
// `fontSize`, `fontFamily`, `lineHeight`, and `contentMaxWidth` are deliberately widened to plain
// `number`/`string` (not narrowed by `as const`) so the appearance updater can patch
// them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their
// literal types.
@@ -651,6 +654,7 @@ interface CommonTheme {
fontSize: Record<keyof typeof FONT_SIZE, number>;
fontFamily: { ui: string; mono: string };
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
contentMaxWidth: number;
iconSize: typeof ICON_SIZE;
fontWeight: typeof FONT_WEIGHT;
borderRadius: typeof BORDER_RADIUS;
@@ -663,6 +667,7 @@ const commonTheme: CommonTheme = {
fontSize: FONT_SIZE,
fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK },
lineHeight: LINE_HEIGHT,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
iconSize: ICON_SIZE,
fontWeight: FONT_WEIGHT,
borderRadius: BORDER_RADIUS,
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import {
clearAssistantImageMetadataCache,
estimateAssistantMessageHeightFromCache,
@@ -52,6 +53,7 @@ describe("assistant image metadata", () => {
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)",
DEFAULT_CONTENT_MAX_WIDTH,
),
).toBeGreaterThan(220);
});
@@ -60,8 +62,14 @@ describe("assistant image metadata", () => {
const source = `data:image/png;base64,${"a".repeat(512)}`;
setAssistantImageMetadata({ source }, { width: 1200, height: 800 });
const imageOnlyHeight = estimateAssistantMessageHeightFromCache(`![Screenshot](${source})`);
const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n![Screenshot](${source})`);
const imageOnlyHeight = estimateAssistantMessageHeightFromCache(
`![Screenshot](${source})`,
DEFAULT_CONTENT_MAX_WIDTH,
);
const mixedHeight = estimateAssistantMessageHeightFromCache(
`Text\n\n![Screenshot](${source})`,
DEFAULT_CONTENT_MAX_WIDTH,
);
expect(imageOnlyHeight).toBeGreaterThan(220);
expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0);
@@ -1,4 +1,3 @@
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createImageSourceCacheKey } from "@/attachments/utils";
@@ -14,7 +13,7 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
const ASSISTANT_IMAGE_INSET = 8;
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
@@ -177,7 +176,10 @@ export function extractAssistantImageSources(markdown: string): string[] {
return parsed.sources;
}
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
export function estimateAssistantMessageHeightFromCache(
markdown: string,
contentMaxWidth: number,
): number | null {
const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown);
if (parsed.sources.length === 0) {
return null;
@@ -189,7 +191,7 @@ export function estimateAssistantMessageHeightFromCache(markdown: string): numbe
.map((metadata) =>
Math.max(
ASSISTANT_IMAGE_MIN_HEIGHT,
Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / metadata.aspectRatio),
),
);
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import {
clearAssistantMessageHeightEstimateCache,
estimateAssistantMessageHeightFromCache,
@@ -27,7 +28,21 @@ describe("assistant message height estimate", () => {
height: 41.1,
});
expect(estimateAssistantMessageHeightFromCache("First paragraph\n\nSecond paragraph")).toBe(97);
expect(
estimateAssistantMessageHeightFromCache(
"First paragraph\n\nSecond paragraph",
DEFAULT_CONTENT_MAX_WIDTH,
),
).toBe(97);
});
it("reads block heights measured at the configured content width", () => {
setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 });
expect(estimateAssistantMessageHeightFromCache("Wide paragraph", 1600)).toBe(44);
expect(
estimateAssistantMessageHeightFromCache("Wide paragraph", DEFAULT_CONTENT_MAX_WIDTH),
).toBeNull();
});
it("falls back to image metadata when markdown blocks are not measured", () => {
@@ -41,6 +56,7 @@ describe("assistant message height estimate", () => {
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)",
DEFAULT_CONTENT_MAX_WIDTH,
),
).toBeGreaterThan(220);
});
@@ -1,9 +1,8 @@
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000;
const ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 16;
const ASSISTANT_MARKDOWN_BLOCK_INSET = 16;
const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24;
const ASSISTANT_MARKDOWN_BLOCK_GAP = 12;
@@ -78,7 +77,10 @@ export function setAssistantMarkdownBlockHeight(input: {
return height;
}
function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number | null {
function estimateAssistantMarkdownBlockHeightFromCache(
markdown: string,
contentMaxWidth: number,
): number | null {
const blocks = splitMarkdownBlocks(markdown);
if (blocks.length === 0) {
return null;
@@ -88,7 +90,7 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number
for (const block of blocks) {
const key = createMarkdownBlockHeightKey({
block,
width: ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH,
width: contentMaxWidth - ASSISTANT_MARKDOWN_BLOCK_INSET,
});
const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined;
if (cachedHeight === undefined) {
@@ -104,10 +106,13 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number
);
}
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
export function estimateAssistantMessageHeightFromCache(
markdown: string,
contentMaxWidth: number,
): number | null {
return (
estimateAssistantMarkdownBlockHeightFromCache(markdown) ??
estimateAssistantImageMessageHeightFromCache(markdown)
estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ??
estimateAssistantImageMessageHeightFromCache(markdown, contentMaxWidth)
);
}
+7 -2
View File
@@ -82,10 +82,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.8",
descriptionFragment: "Previous release",
},
{
id: "claude-sonnet-5-5",
model: "Sonnet 5.5",
descriptionFragment: "Best for everyday tasks",
},
{
id: "claude-sonnet-5",
model: "Sonnet 5",
descriptionFragment: "Best for everyday tasks",
descriptionFragment: "Previous release",
},
{
id: "claude-opus-4-7[1m]",
@@ -430,7 +435,7 @@ try {
"--quiet should print the same ordered model IDs returned by --json",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
"captured --json output should include the current Claude everyday model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
@@ -58,10 +58,23 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
try {
await client.connect();
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
activeTurnBehavior: "interrupt",
});
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
} finally {
await client.close();
}
}
+64
View File
@@ -6906,6 +6906,70 @@ test("creation reconnect observation uses connection-owned subscriptions and rel
expect(phases).toEqual(["accepted", "failed"]);
});
test("uploadFile started while connecting waits for the connection and uploads", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "upload-while-connecting",
logger: createMockLogger(),
reconnect: { enabled: false },
suppressSendErrors: true,
transportFactory: () => mock.transport,
});
clients.push(client);
const connection = client.connect();
expect(client.getConnectionState().status).toBe("connecting");
const upload = client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload-connecting",
chunkSize: 5,
});
void upload.catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
mock.triggerOpen();
await connection;
await vi.waitFor(() => {
const frames = mock.sent
.filter((frame) => typeof frame !== "string")
.map(assertUint8Array)
.map(decodeFileTransferFrame);
expect(frames.map((frame) => frame.opcode)).toEqual([
FileTransferOpcode.FileBegin,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileEnd,
]);
});
const requestIndex = mock.sent.findIndex(
(frame) =>
typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting",
);
expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" });
expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string"));
const file = {
type: "uploaded_file" as const,
id: "upload_req-upload-connecting",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt",
};
mock.triggerMessage(
wrapSessionMessage({
type: "file.upload.response",
payload: { requestId: "req-upload-connecting", file, error: null },
}),
);
await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null });
});
test("uploadFile stops sending chunks when the connection closes between sends", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
+19 -6
View File
@@ -425,6 +425,7 @@ export interface DaemonClientTrace {
export interface SendMessageOptions {
messageId?: string;
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
activeTurnBehavior?: ActiveTurnBehavior;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
@@ -1169,7 +1170,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
}
interface PendingSend {
message: SessionInboundMessage;
send: () => void;
resolve: () => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
@@ -1797,12 +1798,23 @@ export class DaemonClient {
* This prevents waiters from hanging forever when called during connection.
*/
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
return this.sendWhenConnected(() => {
const payload = SessionInboundMessageSchema.parse(message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
});
}
/** Resolves once connected, waiting out a connection that is still being established. */
private whenConnected(): Promise<void> {
return this.sendWhenConnected(() => undefined);
}
private sendWhenConnected(send: () => void): Promise<void> {
const status = this.connectionState.status;
// If connected, send immediately
if (this.transport && status === "connected") {
const payload = SessionInboundMessageSchema.parse(message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
send();
return Promise.resolve();
}
@@ -1823,7 +1835,7 @@ export class DaemonClient {
);
}, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
this.pendingSendQueue.push({ send, resolve, reject, timeoutHandle });
});
}
@@ -1842,8 +1854,7 @@ export class DaemonClient {
clearTimeout(pending.timeoutHandle);
try {
if (this.transport && this.connectionState.status === "connected") {
const payload = SessionInboundMessageSchema.parse(pending.message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
pending.send();
pending.resolve();
} else {
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
@@ -4846,6 +4857,8 @@ export class DaemonClient {
if (!bytes) {
throw new Error("File bytes are required.");
}
// The file frames bypass the send queue, so start only on an open connection.
await this.whenConnected();
const uploadTransport = this.transport;
const resolvedRequestId = this.createRequestId(input.requestId);
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
+7 -1
View File
@@ -744,13 +744,17 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendPromise = agent.send("hello", {
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(sendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "hello",
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
ws.message(
@@ -768,6 +772,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -776,6 +781,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
});
ws.message(
sessionMessage({
+2 -6
View File
@@ -24,7 +24,6 @@ import type {
ProjectPlacementPayload,
WorkspaceProjectDescriptorPayload,
RefreshProvidersSnapshotResponseMessage,
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
@@ -55,6 +54,7 @@ import type {
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
SendMessageOptions,
WaitForFinishResult,
} from "./daemon-client.js";
@@ -276,11 +276,7 @@ export interface PaseoAgentTimelineRefetchOptions {
requestId?: string;
}
export interface PaseoAgentSendOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
}
export type PaseoAgentSendOptions = SendMessageOptions;
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
@@ -125,6 +125,36 @@ describe("daemon-manager commands", () => {
});
});
it("reports a stopped daemon without launching the CLI when no local daemon runs", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
mocks.runExternalCliJsonCommand.mockResolvedValue({
home: mocks.paseoHome,
pid: null,
startedAt: null,
listen: null,
hostname: null,
localDaemon: "stopped",
desktopManaged: false,
connectedDaemon: "not_probed",
});
const status = await createDaemonCommandHandlers().desktop_daemon_status();
expect(status).toMatchObject({ serverId: "", status: "stopped", pid: null });
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
});
it("reports an errored daemon when the local daemon state cannot be read", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
const status = await createDaemonCommandHandlers().desktop_daemon_status();
expect(status).toMatchObject({ serverId: "", status: "errored", pid: null });
expect(status.error).toBeTruthy();
});
it("returns a local credential only for its live managed daemon listen", async () => {
mkdirSync(mocks.paseoHome);
const token = "a".repeat(43);
@@ -224,6 +224,12 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
const home = getPaseoHome();
try {
// The app polls this while no local daemon runs. Answer that case in-process,
// since launching the CLI once per poll keeps spawning processes while idle.
if (!(await readDaemonInstance(home))) {
return statusFromDaemonProbe({ localDaemon: "stopped" }, home);
}
const payload = (await runExternalCliJsonCommand([
"daemon",
"status",
@@ -234,7 +240,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
return statusFromDaemonProbe(payload, home);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
logDesktopDaemonLifecycle("resolveStatus failed", { error: errorMessage });
return {
serverId: "",
status: "errored",
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const skill = readFileSync(path.join(repository, "skills/paseo-plugin/SKILL.md"), "utf8");
function sectionCode(heading: string): string {
const section = skill.slice(skill.indexOf(`\n## ${heading}\n`));
const code = /```tsx\n([\s\S]*?)\n```/.exec(section)?.[1];
if (!code) throw new Error(`No tsx example under "${heading}"`);
return code;
}
// Checks the example as a plugin client file, with the same options as the plugin examples.
function typeErrors(code: string): string[] {
const configPath = path.join(repository, "packages/plugin/tsconfig.examples.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
const file = path.join(repository, "plugin-examples/skill-example/client/example.tsx");
const host = ts.createCompilerHost(options);
const getSourceFile = host.getSourceFile;
host.getSourceFile = (name, language, ...rest) =>
path.resolve(name) === file
? ts.createSourceFile(name, code, language)
: getSourceFile.call(host, name, language, ...rest);
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
const program = ts.createProgram([file], options, host);
return ts
.getPreEmitDiagnostics(program)
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("paseo-plugin skill examples", () => {
it("composer pill example typechecks against the plugin SDK", () => {
expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]);
});
});
@@ -4851,6 +4851,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
});
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
class ModelSession extends TestAgentSession {
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
async setModel(model: string | null) {
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
this.pushEvent({
type: "thinking_option_changed",
provider: "codex",
thinkingOptionId: null,
});
}
describePersistence() {
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
}
}
class ModelClient extends TestAgentClient {
async createSession(config: AgentSessionConfig) {
return new ModelSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new ModelClient() },
registry: storage,
logger,
});
try {
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(agent.id, "target");
await manager.flush();
const stored = await storage.get(agent.id);
expect(stored?.config?.model).toBe("target");
expect(stored?.config?.thinkingOptionId).toBeUndefined();
expect(stored?.persistence?.metadata?.model).toBe("target");
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
class ConfigMutationSession extends TestAgentSession {
@@ -1943,6 +1943,7 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -449,6 +449,63 @@ describe("PluginAgentClientRegistry", () => {
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
});
test("leaves a completed session without a failure when its plugin provider is removed", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.run("hello", { clientMessageId: "completed-message" });
expect(eventsOfType(events, "turn_completed")).toHaveLength(1);
registry.replace([]);
await harness.waitForClose();
expect(eventsOfType(events, "turn_failed")).toEqual([]);
await expect(
session.startTurn("after reload", { clientMessageId: "after-reload" }),
).rejects.toBeInstanceOf(StaleProviderSessionError);
});
test("fails an accepted turn that has not started when its plugin provider is removed", async () => {
const harness = createProviderHarness({
handleInput: async (input, emit) => {
if (input.type !== "session.prompt") return false;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId: "accepted-turn" },
});
return true;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await expect(
session.startTurn("hello", { clientMessageId: "accepted-message" }),
).resolves.toEqual({ turnId: "accepted-turn" });
registry.replace([]);
await harness.waitForClose();
expect(eventsOfType(events, "turn_failed")).toEqual([
expect.objectContaining({ error: "Provider connection closed" }),
]);
});
test("closes a stale session after its plugin provider is replaced", async () => {
const old = createProviderHarness();
const next = createProviderHarness();
@@ -547,6 +547,7 @@ class ProviderRuntimeSession {
string,
Deferred<Extract<ProviderEvent, { type: "session.prompt_result" }>>
>();
private readonly activeTurnIds = new Set<string>();
private terminal = false;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
@@ -702,6 +703,7 @@ class ProviderRuntimeSession {
return;
}
if (event.type === "session.prompt_result") {
if (event.result.type === "turn") this.activeTurnIds.add(event.result.turnId);
this.prompts.get(event.clientMessageId)?.resolve(event);
return;
}
@@ -720,14 +722,16 @@ class ProviderRuntimeSession {
}
connectionClosed(error = new Error("Provider connection closed")): void {
if (!this.terminal) {
this.terminal = true;
// Closing fails only an interrupted turn. An idle session goes stale, and its next prompt
// reopens it from persistence.
if (!this.terminal && this.activeTurnIds.size > 0) {
this.publish({
type: "session.runtime_failed",
sessionId: this.id,
error: { message: error.message },
});
}
this.terminal = true;
this.rejectPending(error);
}
@@ -751,6 +755,10 @@ class ProviderRuntimeSession {
}
private publish(event: ProviderEvent): void {
if (event.type === "session.turn") {
if (event.state === "started") this.activeTurnIds.add(event.turnId);
else this.activeTurnIds.delete(event.turnId);
}
if (event.type === "session.config") this.config = event.config;
if (event.type === "session.commands") this.commands = [...event.commands];
if (event.type === "session.persistence" && this.restoration === "core") {
@@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
resolveVersion: async () => "2.1.284",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
});
const { models } = await client.fetchCatalog({
@@ -516,6 +516,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off");
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
@@ -1289,6 +1291,26 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("rejects disabled thinking on Sonnet 5.5, which only runs with adaptive thinking", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5-5",
});
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
"Thinking option 'off' is not available for model 'claude-sonnet-5-5'",
);
await session.close();
});
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -93,10 +93,19 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
id: "claude-sonnet-5-5",
label: "Sonnet 5.5",
description: "Sonnet 5.5 · Best for everyday tasks",
minimumClaudeCodeVersion: "2.1.284",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Best for everyday tasks",
description: "Sonnet 5 · Previous release",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -57,6 +57,7 @@ describe("getClaudeModels", () => {
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5-5",
"claude-sonnet-5",
"claude-sonnet-5[1m]",
"claude-opus-4-7[1m]",
@@ -90,6 +91,7 @@ describe("getClaudeModels", () => {
["claude-fable-5[1m]", 1_000_000],
["claude-opus-4-8[1m]", 1_000_000],
["claude-opus-4-8", 200_000],
["claude-sonnet-5-5", 1_000_000],
["claude-sonnet-5", 200_000],
["claude-sonnet-5[1m]", 1_000_000],
["claude-opus-4-7[1m]", 1_000_000],
@@ -116,6 +118,9 @@ describe("getClaudeModels", () => {
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
expect(getClaudeModels("2.1.283").map((model) => model.id)).not.toContain("claude-sonnet-5-5");
expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5");
});
it("derives thinking options from model effort capabilities", () => {
@@ -178,6 +183,8 @@ describe("getClaudeModels", () => {
it.each([
["claude-opus-5-5", false, "medium"],
["claude-opus-5-5-20260401", false, "medium"],
["claude-sonnet-5-5", false, "medium"],
["claude-sonnet-5-5-20260928", false, "medium"],
["claude-opus-5", true, "high"],
["claude-opus-5-20260724", true, "high"],
["claude-sonnet-5", true, "high"],
@@ -467,6 +474,16 @@ describe("normalizeClaudeRuntimeModelId", () => {
"claude-opus-5",
);
});
it("does not collapse a prefixed Sonnet 5.5 onto Sonnet 5", () => {
expect(normalizeClaudeRuntimeModelId("anthropic/claude-sonnet-5-5")).toBe("claude-sonnet-5-5");
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-5-20260928-v1:0")).toBe(
"claude-sonnet-5-5",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-20260101-v1:0")).toBe(
"claude-sonnet-5",
);
});
});
describe("parseClaudeCodeVersion", () => {
@@ -600,6 +617,42 @@ describe("Claude Opus 5.5 catalog", () => {
});
});
describe("Claude Sonnet 5.5 catalog", () => {
it("offers one Sonnet 5.5 entry with a 1M context window", () => {
const sonnet55Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-sonnet-5-5"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(sonnet55Models).toEqual([
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", contextWindowMaxTokens: 1_000_000 },
]);
});
it("offers every effort level except off, because Sonnet 5.5 cannot disable thinking", () => {
const sonnet55 = getClaudeModels().find((model) => model.id === "claude-sonnet-5-5");
expect(sonnet55?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(sonnet55?.defaultThinkingOptionId).toBe("medium");
expect(
sonnet55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(["medium"]);
});
it("resolves suffixed and dated Sonnet 5.5 IDs to the single catalog entry", () => {
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.id).toBe("claude-sonnet-5-5");
expect(findClaudeModel("claude-sonnet-5-5-20260928")?.id).toBe("claude-sonnet-5-5");
expect(findClaudeModel("claude-sonnet-5-5-20260928[1m]")?.id).toBe("claude-sonnet-5-5");
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -610,6 +663,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
@@ -660,6 +660,7 @@ async function withCustomCodexProviderHome<T>(
session: AgentSession;
readCaptured: () => CapturedFakeCodexRecord[];
}) => Promise<T>,
providerPrompts: Record<string, string> = {},
): Promise<T> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
@@ -676,6 +677,9 @@ async function withCustomCodexProviderHome<T>(
path.join(providerCodexHome, "prompts", "probe-profile.md"),
"---\ndescription: Provider home prompt\n---\nfrom the provider home\n",
);
for (const [name, content] of Object.entries(providerPrompts)) {
writeFileSync(path.join(providerCodexHome, "prompts", `${name}.md`), content);
}
writeFileSync(
fakeAppServerPath,
`
@@ -753,11 +757,21 @@ async function listPromptCommandsFromCustomCodexHome(): Promise<string[]> {
});
}
async function runPromptFromCustomCodexHome(prompt: string): Promise<CapturedFakeCodexRecord[]> {
async function runPromptFromCustomCodexHome(
prompt: string,
providerPrompts: Record<string, string> = {},
): Promise<CapturedFakeCodexRecord[]> {
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
await session.startTurn(prompt);
return readCaptured();
});
}, providerPrompts);
}
async function expandCustomPrompt(template: string, args: string): Promise<string> {
const records = await runPromptFromCustomCodexHome(`/prompts:template ${args}`, { template });
const turnStart = records.find((record) => record.method === "turn/start");
const params = turnStart?.params as { input: Array<{ text: string }> };
return params.input[0].text;
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
@@ -2484,6 +2498,29 @@ describe("Codex app-server provider", () => {
expect(JSON.stringify(turnStart?.params)).toContain("from the provider home");
});
test("inserts custom prompt arguments literally", async () => {
await expect(
expandCustomPrompt("Implement this exact request: $ARGUMENTS", "Preserve $1 unchanged"),
).resolves.toBe("Implement this exact request: Preserve $1 unchanged");
await expect(
expandCustomPrompt("Treat this as literal replacement text: $VALUE.", 'VALUE="$&"'),
).resolves.toBe("Treat this as literal replacement text: $&.");
await expect(expandCustomPrompt("$1 then $2", '"$2" two')).resolves.toBe("$2 then two");
await expect(expandCustomPrompt("$A and $B", 'A="$B" B=b')).resolves.toBe("$B and b");
await expect(
expandCustomPrompt("$ARGUMENTS", "keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"),
).resolves.toBe("keep __CODEX_DOLLAR_PLACEHOLDER__ as typed");
});
test("expands custom prompt placeholders and escapes", async () => {
await expect(
expandCustomPrompt("$1/$2 $NAME costs $$5, all: $ARGUMENTS, $MISSING", "one NAME=n two"),
).resolves.toBe("one/two n costs $5, all: one NAME=n two, $MISSING");
await expect(
expandCustomPrompt("$$ARGUMENTS $$1 $$NAME $9|$NAME_SUFFIX", "first NAME=value"),
).resolves.toBe("$ARGUMENTS $1 $NAME |$NAME_SUFFIX");
});
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -786,25 +786,21 @@ function expandCodexCustomPrompt(template: string, args: string | undefined): st
positional.push(token);
}
const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__";
let out = template.split("$$").join(dollarPlaceholder);
// Match every placeholder in one pass, so inserted argument text is never expanded again.
const namedPlaceholders = Object.keys(named)
.sort((a, b) => b.length - a.length)
.map((key) => `${escapeRegExp(key)}\\b`);
const placeholder = new RegExp(
`\\$(${["\\$", "ARGUMENTS", "[1-9]", ...namedPlaceholders].join("|")})`,
"g",
);
out = out.split("$ARGUMENTS").join(trimmedArgs);
for (let i = 1; i <= 9; i += 1) {
const value = positional[i - 1] ?? "";
out = out.split(`$${i}`).join(value);
}
const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length);
for (const key of namedKeys) {
const value = named[key] ?? "";
const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g");
out = out.replace(re, value);
}
out = out.split(dollarPlaceholder).join("$");
return out;
return template.replace(placeholder, (match, name: string) => {
if (name === "$") return "$";
if (name === "ARGUMENTS") return trimmedArgs;
if (/^[1-9]$/.test(name)) return positional[Number(name) - 1] ?? "";
return named[name] ?? match;
});
}
interface CodexMcpServerConfig {
@@ -0,0 +1,64 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentPersistenceHandle } from "../../agent-sdk-types.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
test("archived OMP history replays messages and todos without leaving a runtime", async () => {
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-history-purpose-"));
const sessionFile = join(directory, "session.jsonl");
const entries = [
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-1",
parentId: "root",
message: { role: "user", content: "make a plan" },
},
{
type: "message",
id: "todo-1",
parentId: "user-1",
message: {
role: "toolResult",
toolName: "todo",
toolCallId: "call-1",
content: [{ type: "text", text: "one task" }],
details: {
phases: [{ name: "Tasks", tasks: [{ content: "Check history", status: "pending" }] }],
},
},
},
];
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"));
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const handle: AgentPersistenceHandle = {
provider: "omp",
sessionId: "history-1",
nativeHandle: sessionFile,
metadata: { cwd: directory },
};
const session = await client.resumeSession(handle, undefined, undefined, { purpose: "history" });
const events = [];
try {
for await (const event of session.streamHistory()) events.push(event);
} finally {
await session.close();
}
expect(runtime.recordedLaunches).toHaveLength(0);
expect(events).toContainEqual({
type: "timeline",
provider: "omp",
item: {
type: "todo",
items: [{ text: "Check history", status: "pending", completed: false }],
},
});
});
@@ -1,9 +1,10 @@
import { describe, expect, test } from "vitest";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import path, { join } from "node:path";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
@@ -13,6 +14,9 @@ import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
"turn_started",
@@ -47,6 +51,26 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
rpcTimeoutMs: 90_000,
});
});
test("OMP import uses the runtime's custom agent directory without a configured sessionDir", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-import-dir-"));
const agentDir = path.join(root, "agent");
const sessionFile = path.join(agentDir, "sessions", "project", "session.jsonl");
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(
sessionFile,
JSON.stringify({ type: "session", id: "custom-dir", cwd: root, timestamp: "2026-09-28" }),
);
const client = new OmpAgentClient({
logger: createTestLogger(),
runtime: new FakeOmp(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
expect(await client.listImportableSessions({ cwd: root })).toEqual([
expect.objectContaining({ providerHandleId: sessionFile }),
]);
});
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
@@ -235,7 +259,7 @@ describe("OMP agent client and session", () => {
[expect.objectContaining({ name: "create_agent" })],
]);
expect(omp.capabilities()).toMatchObject({
supportsMcpServers: false,
supportsMcpServers: true,
supportsNativePaseoTools: true,
});
});
@@ -247,6 +271,26 @@ describe("OMP agent client and session", () => {
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"]));
});
test("launches with auto thinking when auto is selected", async () => {
const omp = new OmpHarness();
await omp.start({ thinkingOptionId: "auto" });
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
});
test("keeps auto thinking when resuming a session", async () => {
const omp = new OmpHarness();
await omp.resume(
{
user: { id: "user-auto", text: "continue" },
assistant: { id: "assistant-auto", text: "ready" },
},
{ thinkingOptionId: "auto" },
);
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
});
test("launches with write approval mode", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "write" });
@@ -443,6 +487,182 @@ describe("OMP agent client and session", () => {
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("fails a turn when the provider idle gate passes its deadline", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await scheduler.waitForWaits(1);
omp.runtime().emit({
type: "tool_execution_start",
toolCallId: "tool-at-deadline",
toolName: "bash",
args: { command: "sleep 30" },
});
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
await new Promise((resolve) => setTimeout(resolve, 2));
scheduler.retry();
await expect(completion).rejects.toThrow(/provider idle/i);
expect(omp.runningToolCallIds()).toEqual([]);
});
test("steers a running turn and correlates a template-expanded echo exactly once", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first", { clientMessageId: "client-first" });
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("first", "native-first");
await expect(
session.steerActiveTurn?.("expand template", {
expectedTurnId: turnId,
clientMessageId: "client-steer",
}),
).resolves.toEqual({ status: "accepted" });
runtime.acceptPrompt("expanded prompt", "native-steer");
runtime.acceptPrompt("expanded prompt", "native-steer");
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
expect.objectContaining({ messageId: "native-first", clientMessageId: "client-first" }),
expect.objectContaining({ messageId: "native-steer", clientMessageId: "client-steer" }),
]);
});
test("reports a rejected steer as unavailable", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first");
omp.runtime().steerError = new Error("extension command cannot be steered");
await expect(
session.steerActiveTurn?.("extension input", { expectedTurnId: turnId }),
).resolves.toEqual({ status: "unavailable" });
});
test("propagates an OMP steer transport timeout", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first");
const timeout = new Error(
"OMP RPC request timed out phase=steer elapsedMs=60000 timeoutMs=60000",
);
omp.runtime().steerError = timeout;
await expect(session.steerActiveTurn?.("second", { expectedTurnId: turnId })).rejects.toBe(
timeout,
);
});
test("shows OMP's fallback model without persisting it as the selected model", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const runtime = omp.runtime();
runtime.state = {
...runtime.state,
model: { provider: "openrouter", id: "google/gemini-3.8-flash" },
};
runtime.emit({
type: "retry_fallback_applied",
from: "openrouter/google/gemini-3.8-flash",
to: "openrouter/other/model",
role: "primary",
});
runtime.state = { ...runtime.state, model: { provider: "openrouter", id: "other/model" } };
runtime.state = { ...runtime.state, fastModeEnabled: true, fastModeActive: false };
runtime.emit({ type: "model_changed" });
await waitForImmediate();
expect(omp.eventTypes()).toContain("model_changed");
expect((await omp.requireSession().getRuntimeInfo()).model).toBe("openrouter/other/model");
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
"openrouter/google/gemini-3.8-flash",
);
expect(omp.requireSession().features).toEqual([
expect.objectContaining({
value: true,
description: expect.stringMatching(/does not apply/i),
}),
]);
});
test("Fast stays selected when OMP says it is inactive for the current model", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: false };
await session.setFeature?.("fast_mode", true);
expect(omp.runtime().setFastModeRequests).toEqual([true]);
expect(session.features).toEqual([
expect.objectContaining({
id: "fast_mode",
value: true,
description: expect.stringMatching(/does not apply to this model/i),
tooltip: expect.stringMatching(/does not apply to this model/i),
}),
]);
});
test("shows Fast only when OMP reports its state fields", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const {
fastModeEnabled: _enabled,
fastModeActive: _active,
...olderState
} = omp.runtime().state;
omp.runtime().state = olderState;
await session.getRuntimeInfo();
expect(session.features).toEqual([]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
await session.getRuntimeInfo();
expect(session.features).toHaveLength(1);
});
test("switching model refreshes whether Fast applies", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.setFeature?.("fast_mode", true);
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
{
...omp.runtime().state,
model: omp.runtime().setModelResult,
fastModeEnabled: true,
fastModeActive: false,
},
]);
await session.setModel?.("openrouter/other/model");
expect(session.features).toEqual([
expect.objectContaining({
value: true,
description: expect.stringMatching(/does not apply/i),
}),
]);
});
test("restores Fast from the initial OMP state on create and resume", async () => {
const created = new OmpHarness();
await created.start({ featureValues: { fast_mode: true } });
expect(created.runtime().setFastModeRequests).toEqual([true]);
expect(created.runtime().getStateRequestCount).toBe(1);
const resumed = new OmpHarness();
await resumed.resume(
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
{ featureValues: { fast_mode: true } },
);
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
expect(resumed.runtime().getStateRequestCount).toBe(1);
});
test("does not complete on OMP's extension-notice agent_end", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -515,13 +735,162 @@ describe("OMP agent client and session", () => {
message: "Background job DocsSmokeTwo completed",
},
]);
// Non-notice custom messages still fall through as assistant messages.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([
{ text: "done" },
{ text: "plain custom status text" },
// Non-notice custom messages still fall through as assistant messages with
// their own id so the stream coalescer never glues them onto the open reply.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([
{ type: "assistant_message", text: "done", messageId: "omp-assistant-1" },
{ type: "assistant_message", text: "plain custom status text", messageId: "omp-custom-1" },
]);
});
test("shows a concise error for a failed tool while keeping shell output", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "failed-shell",
toolName: "bash",
args: { command: "false" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "failed-shell",
toolName: "bash",
result: {
content: [{ type: "text", text: "Command exited with code 1" }],
details: {},
isError: true,
exitCode: 1,
},
isError: true,
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
status: "failed",
error: "Command exited with code 1",
detail: { type: "shell", command: "false", exitCode: 1 },
});
});
test("uses the exit message when a failed shell has no output", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "empty-shell",
toolName: "bash",
args: { command: "false" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "empty-shell",
toolName: "bash",
result: {
content: [
{
type: "text",
text: "(no output)\n\nWall time: 0.02 seconds\n\nCommand exited with code 1",
},
],
isError: true,
},
isError: true,
});
expect(omp.timeline().at(-1)).toMatchObject({ error: "Command exited with code 1" });
});
test("does not duplicate the typed invocation for a live skill expansion", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.runPromptWithCustomMessage(
"hello",
{
role: "custom",
content: "[IMPORTANT] Full skill body",
customType: "skill-prompt",
attribution: "user",
details: { name: "commit" },
display: true,
id: "skill-1",
},
"done",
);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "hello", messageId: "user-1" },
]);
expect(omp.timeline()).not.toContainEqual(
expect.objectContaining({ text: "[IMPORTANT] Full skill body" }),
);
});
test.each([
{ kind: "initial", priorPrompt: false },
{ kind: "follow-up", priorPrompt: true },
])("correlates a $kind skill invocation without a user echo", async ({ priorPrompt }) => {
const omp = new OmpHarness();
await omp.start();
if (priorPrompt) {
await omp.runPrompt("Reply OK", "OK");
}
const typed = "/skill:tldr Summarize: hi.";
const runtime = omp.runtime();
const promptStarted = runtime.nextPrompt();
const run = omp.requireSession().run(typed, { clientMessageId: "client-skill" });
await promptStarted;
runtime.beginTurn();
runtime.emit({
type: "message_end",
message: {
role: "custom",
content: "[IMPORTANT] Full skill body",
customType: "skill-prompt",
attribution: "user",
details: { name: "tldr", args: "Summarize: hi." },
display: true,
id: "skill-1",
},
});
runtime.streamAssistantText("done");
runtime.finishTurn();
await run;
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
...(priorPrompt ? [{ type: "user_message", text: "Reply OK", messageId: "user-1" }] : []),
{ type: "user_message", text: typed, clientMessageId: "client-skill" },
]);
});
test("marks an OMP web search details error as failed even without isError", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "search-failed",
toolName: "web_search",
args: { query: "Paseo" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "search-failed",
toolName: "web_search",
result: {
content: [{ type: "text", text: "Error: All web search providers failed" }],
details: {
response: { provider: "mojeek", sources: [] },
error: "All web search providers failed",
},
},
isError: false,
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
status: "failed",
error: "All web search providers failed",
detail: { type: "search", query: "Paseo" },
});
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -540,6 +909,28 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("completes a no-turn notify with one notification and no assistant text", async () => {
const scheduler = new ManualNoTurnScheduler();
const omp = new OmpHarness({ noTurnScheduler: scheduler });
await omp.start();
const prompt = await omp.startPromptWithFalseLocalOnlyResult("/autoresearch off");
omp.emit({
type: "extension_ui_request",
id: "local-notify",
method: "notify",
message: "Autoresearch mode disabled",
notifyType: "info",
});
scheduler.settle();
await expect(prompt.completion).resolves.toMatchObject({ finalText: "" });
expect(omp.completedTurnCount()).toBe(1);
expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
{ type: "notification", level: "info", message: "Autoresearch mode disabled" },
]);
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
});
test("waits for a delayed queued model turn after OMP's local-only result", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -659,6 +1050,33 @@ describe("OMP agent client and session", () => {
]);
});
test("shows OMP notifications during a turn and while idle with their levels", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.startTurn("work");
omp.runtime().beginTurn();
omp.emit({
type: "extension_ui_request",
id: "n1",
method: "notify",
message: "Working",
notifyType: "warning",
});
omp.runtime().finishTurn();
omp.emit({
type: "extension_ui_request",
id: "n2",
method: "notify",
message: "Done",
notifyType: "info",
});
expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
{ type: "notification", level: "warning", message: "Working" },
{ type: "notification", level: "info", message: "Done" },
]);
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
});
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -724,41 +1142,6 @@ describe("OMP agent client and session", () => {
]);
});
test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-user-1",
toolName: "ask_user",
args: { allowComment: true, allowFreeform: true, allowMultiple: false },
});
omp.emit({
type: "extension_ui_request",
id: "select-combined",
method: "select",
title: "Choose",
options: ["First", "✏️ Type custom response..."],
optionDetails: [{ description: "First detail" }, { description: "ignored" }],
});
const combinedInput = omp.pendingPermissions()[0]?.input;
expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
{ label: "First", description: "First detail" },
]);
expect(combinedInput).toMatchObject({
questions: [{ allowOther: true }, { header: "Comment" }],
});
await omp.respondToPermission("select-combined", {
behavior: "allow",
updatedInput: { answers: { Response: "custom", Comment: "note" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-combined",
response: { value: "✏️ Type custom response..." },
});
});
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
@@ -775,10 +1158,99 @@ describe("OMP agent client and session", () => {
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
await expect(omp.setMode("ask")).resolves.toBeUndefined();
await expect(omp.currentMode()).resolves.toBe("ask");
expect(omp.runtimeLaunches()[1]?.argv).toContain("--approval-mode");
expect(omp.runtimeLaunches()[1]?.argv).toContain("always-ask");
});
test("restarts the same conversation after an idle process exit", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
const previousId = omp.runtime().state.sessionId;
omp.processExit("OMP RPC process exited with code null and signal SIGKILL\nBun crashed");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code null and signal SIGKILL\nBun crashed",
]);
await omp.startTurn("remember the conversation");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtimeLaunches()[1]?.session).toBe("/tmp/omp-session");
expect(omp.runtime().state.sessionId).toBe(previousId);
expect(omp.threadStartedSessionIds()).toEqual([]);
expect(omp.runtime().prompts).toEqual([
{ message: "remember the conversation", imageCount: 0 },
]);
});
test("reports a mid-turn process exit and resumes on the following prompt", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("sleep 30");
omp.runtime().beginTurn();
omp.processExit("OMP RPC process exited with code 137 and signal null\nOOM");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code 137 and signal null\nOOM",
]);
await omp.startTurn("continue");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]);
});
test("reports an immediate relaunch failure without retrying in a loop", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code null and signal SIGKILL");
omp.failNextStart(new Error("Bun failed during startup"));
await omp.startTurn("continue");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code null and signal SIGKILL",
"Bun failed during startup",
]);
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("closes a replacement process if the session closes during relaunch", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
await omp.close();
await turn;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
test("announces a fresh native session when an internal agent recovers", async () => {
const omp = new OmpHarness();
await omp.start({ internal: true });
const previousId = omp.runtime().state.sessionId;
omp.processExit("OMP RPC process exited with code 1 and signal null");
await omp.startTurn("continue");
expect(omp.runtimeLaunches()[1]?.argv).toContain("--no-session");
expect(omp.runtime().state.sessionId).not.toBe(previousId);
expect(omp.threadStartedSessionIds()).toEqual([omp.runtime().state.sessionId]);
});
test("leaves the current approval mode in place when relaunch fails", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
omp.failNextStart(new Error("OMP launch failed"));
await expect(omp.setMode("ask")).rejects.toThrow("OMP launch failed");
expect(await omp.currentMode()).toBe("full");
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("rejects a running approval-mode change until the turn ends", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
await omp.requireStartTurn("work");
await expect(omp.setMode("ask")).resolves.toEqual({
type: "warning",
message: "Start a new OMP session to change approval mode",
message: "Change approval mode once the current turn ends",
});
expect(omp.runtimeLaunches()).toHaveLength(1);
expect(await omp.currentMode()).toBe("full");
});
test("rewinds natively, interrupts, and shuts down", async () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
import { describe, expect, test } from "vitest";
import { OmpHarness } from "./test-utils/omp-harness.js";
function startAsk(omp: OmpHarness, multi: boolean): void {
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-1",
toolName: "ask",
args: {
questions: [
{
id: "colors",
question: "Which colors?",
multi,
options: [
{ label: "Red", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
],
},
});
}
function select(omp: OmpHarness, id: string, title: string, options: string[]): void {
omp.emit({ type: "extension_ui_request", id, method: "select", title, options });
}
describe("OMP ask RPC UI", () => {
test("recognizes ask arguments from the assistant message before the UI request", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "ask-1",
name: "ask",
arguments: {
questions: [
{
id: "colors",
question: "Which colors?",
multi: true,
options: [
{ label: "Red", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
],
},
},
],
},
});
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({
multiSelect: true,
allowOther: true,
});
startAsk(omp, true);
});
test("answers the multi-select loop from one Paseo question", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()).toHaveLength(1);
expect(omp.pendingPermissions()[0]).toMatchObject({
kind: "question",
input: {
questions: [
{
question: "Which colors?",
multiSelect: true,
allowOther: true,
options: [{ label: "Red" }, { label: "Blue" }],
},
],
},
});
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Blue, Red" } },
});
select(omp, "select-2", "(1 selected) Which colors?", [
"Red",
"Blue",
"✓ Done selecting",
"Other (type your own)",
]);
select(omp, "select-3", "(2 selected) Which colors?", [
"Red",
"Blue",
"✔ Done selecting",
"Other (type your own)",
]);
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Blue" } },
{ id: "select-2", response: { value: "Red" } },
{ id: "select-3", response: { value: "✔ Done selecting" } },
]);
});
test("passes a single-select Other answer through OMP's editor", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ allowOther: true });
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Violet" } },
});
omp.emit({
type: "extension_ui_request",
id: "editor-1",
method: "editor",
title: "Which colors? ○ Red ○ Blue ◉ Other (type your own) Enter your response:",
});
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Other (type your own)" } },
{ id: "editor-1", response: { value: "Violet" } },
]);
});
test("passes multi-select choices and Other through the same editor flow", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Red, Violet" } },
});
select(omp, "select-2", "(1 selected) Which colors?", [
"Red",
"Blue",
"✓ Done selecting",
"Other (type your own)",
]);
omp.emit({
type: "extension_ui_request",
id: "editor-1",
method: "editor",
title: "(1 selected) Which colors?",
});
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Red" } },
{ id: "select-2", response: { value: "Other (type your own)" } },
{ id: "editor-1", response: { value: "Violet" } },
]);
});
test("cancels an ask without leaving replay state behind", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", { behavior: "deny" });
select(omp, "select-after-cancel", "Another question", ["One", "Two"]);
expect(omp.extensionUiResponses()).toEqual([{ id: "select-1", response: { cancelled: true } }]);
expect(omp.pendingPermissions()).toHaveLength(1);
expect(omp.pendingPermissions()[0]?.id).toBe("select-after-cancel");
});
test("keeps single choices, tool approvals, and free-form input independent", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Blue" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-1",
response: { value: "Blue" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "ask-1",
toolName: "ask",
result: "Blue",
isError: false,
});
omp.requestToolApproval({ id: "approval", tool: "bash", detail: "echo hi" });
expect(omp.pendingPermissions()[0]).toMatchObject({ id: "approval", kind: "tool" });
await omp.respondToPermission("approval", { behavior: "allow" });
expect(omp.extensionUiResponses()).toContainEqual({
id: "approval",
response: { value: "Approve" },
});
omp.emit({ type: "extension_ui_request", id: "input", method: "input", title: "Type a value" });
expect(omp.pendingPermissions()[0]).toMatchObject({ id: "input", kind: "question" });
await omp.respondToPermission("input", {
behavior: "allow",
updatedInput: { answers: { Response: "typed value" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "input",
response: { value: "typed value" },
});
});
test("recognizes OMP's numbered question title", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ multiSelect: true });
});
test("keeps descriptions with their labels when a select event reorders rows", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Blue", "Red", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
{ label: "Blue", description: "Cool" },
{ label: "Red", description: "Warm" },
]);
});
test("keeps a recommended option's description with its display label", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", [
"Red (Recommended)",
"Blue",
"Other (type your own)",
]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
{ label: "Red (Recommended)", description: "Warm" },
{ label: "Blue", description: "Cool" },
]);
});
test("cancels cleanly when OMP omits Done selecting in a multi-question ask", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-1",
toolName: "ask",
args: {
questions: [
{
id: "colors",
question: "Which colors?",
multi: true,
options: [{ label: "Red" }, { label: "Blue" }],
},
{
id: "shape",
question: "Which shape?",
options: [{ label: "Round" }, { label: "Square" }],
},
],
},
});
select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Red" } },
});
// OMP's RPC select omits the right-arrow navigation callback and, with
// allowForward true, also omits its Done row on every follow-up request.
select(omp, "select-2", "(1 selected) Which colors? (1/2)", [
"Red",
"Blue",
"Other (type your own)",
]);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Red" } },
{ id: "select-2", response: { cancelled: true } },
]);
expect(omp.pendingPermissions()).toHaveLength(0);
omp.emit({
type: "tool_execution_end",
toolCallId: "ask-1",
toolName: "ask",
result: "Ask tool was cancelled by the user",
isError: true,
});
expect(omp.timeline()).toContainEqual(
expect.objectContaining({
type: "tool_call",
name: "ask",
status: "failed",
}),
);
});
});

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