mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 20:05:11 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07893d845 | ||
|
|
985ff9dcd4 | ||
|
|
5ef83ac70a | ||
|
|
48c2067a1d | ||
|
|
0b00e79e17 | ||
|
|
c2f1cb46ae | ||
|
|
254c7a0831 | ||
|
|
45136b1f6f | ||
|
|
5359250c47 | ||
|
|
148b58a8bf | ||
|
|
fff89a3c17 | ||
|
|
5b0cb13f02 | ||
|
|
ae45e182bd | ||
|
|
59d2fe6958 | ||
|
|
af763060c5 | ||
|
|
046c206577 | ||
|
|
17e313e19a |
@@ -113,38 +113,6 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -20,16 +20,9 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
|
||||
+12
-15
@@ -510,6 +510,18 @@ before calling status; it exposes your diagnostic through the normal provider di
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
Implement optional `installation` to fill the **Installation** section of provider settings. The
|
||||
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
|
||||
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
|
||||
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
|
||||
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
|
||||
it, so a slow check reports that deadline. `update` has no request timeout and streams output
|
||||
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
|
||||
sends `provider.installation.cancel`, which aborts the callback's `signal`.
|
||||
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
|
||||
author contract is in the
|
||||
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
@@ -624,21 +636,6 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
@@ -263,6 +263,48 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Installation and updates
|
||||
|
||||
The **Installation** section of provider settings reads `AgentClient.installation`. Built-ins set
|
||||
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
|
||||
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
|
||||
owns deadlines, caching and the one running update per provider, so a provider only answers.
|
||||
|
||||
Declare only what the vendor offers:
|
||||
|
||||
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
|
||||
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
|
||||
and never parses or compares them.
|
||||
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
|
||||
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
|
||||
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
|
||||
build a release lookup of your own.
|
||||
- `update` runs the CLI's own update command and passes its output through, using
|
||||
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
|
||||
exactly what the same command does in a terminal.
|
||||
|
||||
The three calls are separate because they cost differently. `inspect` runs whenever someone opens
|
||||
provider settings, with a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
|
||||
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
|
||||
holds for an hour while `inspect` reports the same version string. Report every part of the version
|
||||
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
|
||||
stops.
|
||||
|
||||
The app shows **Update** only when a check answers `available` and the provider declares `update`.
|
||||
**Update** and **Update instructions** live in the Updates row, which needs a check. A provider
|
||||
with `update` or `updateUrl` and no check, such as Copilot or OpenCode, shows neither.
|
||||
|
||||
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
|
||||
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
|
||||
so its message asks the user to restart the daemon.
|
||||
|
||||
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
|
||||
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
|
||||
that profile launches. Clients for `extends: "acp"` providers declare no installation.
|
||||
|
||||
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
|
||||
updater during development replaces the CLI on your machine.
|
||||
|
||||
## Usage sources
|
||||
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -80,17 +79,12 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -634,15 +628,6 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -654,8 +639,6 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -664,9 +647,6 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
@@ -33,8 +33,20 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
|
||||
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
|
||||
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
|
||||
*/
|
||||
async function searchModels(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await page
|
||||
.getByText("Custom models", { exact: true })
|
||||
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -20,42 +19,6 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -99,14 +62,6 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -221,18 +172,6 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import { ProviderInstallationSection } from "./provider-installation-section";
|
||||
import { useProviderInstallation } from "./provider-installation";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
@@ -632,9 +634,12 @@ export function ProviderDiagnosticSheet({
|
||||
[additionalModels, q],
|
||||
);
|
||||
|
||||
const installation = useProviderInstallation({ serverId, provider, visible });
|
||||
const refreshInstallation = installation.refresh;
|
||||
const handleRefreshModels = useCallback(() => {
|
||||
void refresh([provider]);
|
||||
}, [provider, refresh]);
|
||||
refreshInstallation();
|
||||
}, [provider, refresh, refreshInstallation]);
|
||||
|
||||
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
|
||||
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
|
||||
@@ -689,6 +694,11 @@ export function ProviderDiagnosticSheet({
|
||||
})}
|
||||
snapPoints={MAIN_SNAP_POINTS}
|
||||
>
|
||||
<ProviderInstallationSection
|
||||
state={installation.state}
|
||||
updates={installation.updates}
|
||||
action={installation.action}
|
||||
/>
|
||||
<ProviderModalBody
|
||||
discoveredCount={discoveredModels.length}
|
||||
additionalCount={additionalModels.length}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import {
|
||||
ProviderInstallationSection,
|
||||
type ProviderInstallationState,
|
||||
type ProviderUpdateAction,
|
||||
type ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const mounted: { root: Root; container: HTMLDivElement }[] = [];
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const INSTALL_URL = "https://example.com/install";
|
||||
const UPDATE_URL = "https://example.com/update";
|
||||
|
||||
function mount(
|
||||
state: ProviderInstallationState,
|
||||
updates: ProviderUpdatesState = { status: "hidden" },
|
||||
action?: ProviderUpdateAction,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(<ProviderInstallationSection state={state} updates={updates} action={action} />),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
const view = within(container);
|
||||
return {
|
||||
view,
|
||||
installButton: () => view.queryByRole("button", { name: "Install" }),
|
||||
installLink: () => view.queryByRole("link", { name: "Install instructions" }),
|
||||
};
|
||||
}
|
||||
|
||||
function ready(installation: ProviderInstallationState & { status: "ready" }) {
|
||||
return installation;
|
||||
}
|
||||
|
||||
describe("install link visibility", () => {
|
||||
it("offers the Install button when the provider reports it is not installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { installUrl: INSTALL_URL, inspection: { status: "not_installed" } },
|
||||
}),
|
||||
);
|
||||
expect(section.installButton()).not.toBeNull();
|
||||
expect(section.installLink()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link when the installation check failed", async () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "error", message: "version probe exploded" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect.element(section.view.getByText("version probe exploded")).toBeVisible();
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link for a provider that only declares where to install it", () => {
|
||||
const section = mount(ready({ status: "ready", installation: { installUrl: INSTALL_URL } }));
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no install link once the provider reports it is installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "installed", version: "1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section.installLink()).toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update check", () => {
|
||||
const installed = ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "0.165.0" },
|
||||
},
|
||||
});
|
||||
|
||||
it("shows the reported versions and message as written, with Update instructions", async () => {
|
||||
const section = mount(installed, {
|
||||
status: "ready",
|
||||
updateCheck: {
|
||||
status: "available",
|
||||
versions: ["0.168.0", "nightly (unstable)"],
|
||||
message: "Restart to finish",
|
||||
},
|
||||
});
|
||||
await expect.element(section.view.getByText("Update available")).toBeVisible();
|
||||
expect(
|
||||
section.view.getAllByTestId("provider-updates-version").map((node) => node.textContent),
|
||||
).toEqual(["0.168.0", "nightly (unstable)"]);
|
||||
await expect.element(section.view.getByText("Restart to finish")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "unknown",
|
||||
updates: { status: "ready", updateCheck: { status: "unknown", message: "Offline" } },
|
||||
},
|
||||
{
|
||||
name: "a thrown or timed-out check",
|
||||
updates: { status: "ready", updateCheck: { status: "error", message: "Offline" } },
|
||||
},
|
||||
{ name: "a failed request", updates: { status: "error", message: "Offline" } },
|
||||
] as const)("shows $name as inconclusive, never up to date", async ({ updates }) => {
|
||||
const section = mount(installed, updates);
|
||||
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
|
||||
await expect.element(section.view.getByText("Offline")).toBeVisible();
|
||||
expect(section.view.queryByText("Up to date")).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("link", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("shows up to date only when the provider says so", async () => {
|
||||
const section = mount(installed, { status: "ready", updateCheck: { status: "current" } });
|
||||
await expect.element(section.view.getByText("Up to date")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update action", () => {
|
||||
const installed = (canUpdate: boolean) =>
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
...(canUpdate ? { canUpdate: true } : {}),
|
||||
},
|
||||
});
|
||||
const available: ProviderUpdatesState = {
|
||||
status: "ready",
|
||||
updateCheck: { status: "available", versions: ["2.0.0"] },
|
||||
};
|
||||
function action(overrides: Partial<ProviderUpdateAction> = {}) {
|
||||
let starts = 0;
|
||||
const value: ProviderUpdateAction = {
|
||||
current: null,
|
||||
starting: false,
|
||||
startError: null,
|
||||
start: () => {
|
||||
starts += 1;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { value, starts: () => starts };
|
||||
}
|
||||
const operation = { id: "op", startedAt: "2026-11-13T00:00:00.000Z" };
|
||||
const finishedAt = "2026-11-13T00:01:00.000Z";
|
||||
|
||||
it("offers Update when an update is available and the provider runs its own", () => {
|
||||
const control = action();
|
||||
const section = mount(installed(true), available, control.value);
|
||||
const button = section.view.getByRole("button", { name: "Update" });
|
||||
act(() => button.click());
|
||||
expect(control.starts()).toBe(1);
|
||||
expect(section.view.queryByRole("button", { name: "Update instructions" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Update instructions when the provider does not run its own update", async () => {
|
||||
const section = mount(installed(false), available, action().value);
|
||||
expect(section.view.queryByRole("button", { name: "Update" })).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("shows a running update with its output so far", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "running" },
|
||||
output: [
|
||||
{ type: "message", text: "Running codex update\n" },
|
||||
{ type: "stdout", text: "added 1 package\n" },
|
||||
],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updating…");
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"Running codex update\nadded 1 package\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the provider's success message as written", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
action({
|
||||
current: {
|
||||
operation: {
|
||||
...operation,
|
||||
status: "succeeded",
|
||||
finishedAt,
|
||||
message: " Restart agents \n",
|
||||
},
|
||||
output: [],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
|
||||
expect(section.view.getByTestId("provider-update-message").textContent).toBe(
|
||||
" Restart agents \n",
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-update-output")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failure with its message, its output and the update instructions", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "failed", finishedAt, message: "exited with code 1" },
|
||||
output: [{ type: "stderr", text: "npm error EACCES\n" }],
|
||||
truncated: true,
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect.element(section.view.getByText("exited with code 1")).toBeVisible();
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"npm error EACCES\n",
|
||||
);
|
||||
await expect.element(section.view.getByText("Earlier output was dropped.")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("link", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
await expect.element(section.view.getByRole("button", { name: "Update" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows why the host refused to start the update", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({ startError: "Provider codex cannot update itself" }).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect
|
||||
.element(section.view.getByText("Provider codex cannot update itself"))
|
||||
.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
ProviderInstallation,
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateCheck,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export type ProviderInstallationState =
|
||||
| { status: "unsupported" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; installation: ProviderInstallation | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/** `hidden` until the provider is installed, and when it cannot check for updates. */
|
||||
export type ProviderUpdatesState =
|
||||
| { status: "hidden" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; updateCheck: ProviderInstallationUpdateCheck }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/** Running the provider's own update. Absent when the host cannot report updates. */
|
||||
export interface ProviderUpdateAction {
|
||||
/** The provider's latest update since the host started, or null. */
|
||||
current: ProviderInstallationUpdate | null;
|
||||
/** The request to start an update is in flight. */
|
||||
starting: boolean;
|
||||
/** Why the host did not start the update. */
|
||||
startError: string | null;
|
||||
start(): void;
|
||||
}
|
||||
|
||||
export function ProviderInstallationSection({
|
||||
state,
|
||||
updates,
|
||||
action,
|
||||
}: {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
action?: ProviderUpdateAction;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "unsupported") return null;
|
||||
if (
|
||||
state.status === "ready" &&
|
||||
!state.installation?.inspection &&
|
||||
!state.installation?.installUrl
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section} testID="provider-installation">
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>
|
||||
{t("settings.providers.installation.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<InstallationRow state={state} />
|
||||
{updates.status !== "hidden" && state.status === "ready" ? (
|
||||
<UpdatesRow
|
||||
updates={updates}
|
||||
updateUrl={state.installation?.updateUrl}
|
||||
action={state.installation?.canUpdate ? action : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{action && (action.current || action.startError) ? (
|
||||
<UpdateRunRow
|
||||
action={action}
|
||||
updateUrl={state.status === "ready" ? state.installation?.updateUrl : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallInstructionsLink({ installUrl }: { installUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ExternalLink
|
||||
href={installUrl}
|
||||
label={t("settings.providers.installation.installInstructions")}
|
||||
testID="provider-installation-install-instructions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallationRow({ state }: { state: ProviderInstallationState }) {
|
||||
const { t } = useTranslation();
|
||||
const installation = state.status === "ready" ? state.installation : null;
|
||||
const installUrl = installation?.installUrl;
|
||||
const handleInstall = useCallback(() => {
|
||||
if (installUrl) void openExternalUrl(installUrl);
|
||||
}, [installUrl]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const inspection = installation?.inspection;
|
||||
if (inspection?.status === "not_installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.providers.installation.notInstalled")}
|
||||
</Text>
|
||||
{installUrl ? (
|
||||
<Button variant="outline" onPress={handleInstall}>
|
||||
{t("settings.providers.installation.install")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (inspection?.status === "installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
{inspection.version ? (
|
||||
<Text
|
||||
style={styles.version}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-installation-version"
|
||||
>
|
||||
{inspection.version}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.value}>{t("settings.providers.installation.installed")}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// The installed state is unknown: the check failed, or the provider only links to a guide.
|
||||
let message: string | null = null;
|
||||
if (state.status === "error") message = state.message;
|
||||
else if (inspection?.status === "error") message = inspection.message;
|
||||
if (!message) {
|
||||
return installUrl ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.failed")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{message}</Text>
|
||||
{installUrl ? (
|
||||
<View style={styles.link}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatesRow({
|
||||
updates,
|
||||
updateUrl,
|
||||
action,
|
||||
}: {
|
||||
updates: Exclude<ProviderUpdatesState, { status: "hidden" }>;
|
||||
updateUrl: string | undefined;
|
||||
/** Present when the provider runs its own update. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (updates.status === "loading") {
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.updates")}</Text>
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
if (updateCheck?.status === "current") {
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.updates")}</Text>
|
||||
<Text style={styles.value} testID="provider-updates-current">
|
||||
{t("settings.providers.installation.upToDate")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (updateCheck?.status === "available") {
|
||||
// Versions are opaque; drop repeats so each one keys its own line.
|
||||
const versions = [...new Set(updateCheck.versions ?? [])];
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.providers.installation.updateAvailable")}
|
||||
</Text>
|
||||
{versions.map((version) => (
|
||||
<Text
|
||||
key={version}
|
||||
style={styles.availableVersion}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-updates-version"
|
||||
>
|
||||
{version}
|
||||
</Text>
|
||||
))}
|
||||
{updateCheck.message ? (
|
||||
<Text style={settingsStyles.rowHint}>{updateCheck.message}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<AvailableUpdateButton action={action} updateUrl={updateUrl} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// A check that threw, timed out or could not tell is never shown as up to date.
|
||||
let message: string | undefined;
|
||||
if (updates.status === "error") message = updates.message;
|
||||
else if (updateCheck?.status === "unknown" || updateCheck?.status === "error")
|
||||
message = updateCheck.message;
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.providers.installation.updatesFailed")}
|
||||
</Text>
|
||||
{message ? <Text style={settingsStyles.rowHint}>{message}</Text> : null}
|
||||
{updateUrl ? (
|
||||
<View style={styles.link}>
|
||||
<ExternalLink
|
||||
href={updateUrl}
|
||||
label={t("settings.providers.installation.updateInstructions")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Update runs the provider's updater; without one, the provider's update guide opens. */
|
||||
function AvailableUpdateButton({
|
||||
action,
|
||||
updateUrl,
|
||||
}: {
|
||||
action: ProviderUpdateAction | undefined;
|
||||
updateUrl: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleOpenInstructions = useCallback(() => {
|
||||
if (updateUrl) void openExternalUrl(updateUrl);
|
||||
}, [updateUrl]);
|
||||
if (action)
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={action.start}
|
||||
loading={action.starting || action.current?.operation.status === "running"}
|
||||
testID="provider-update-start"
|
||||
>
|
||||
{t("settings.providers.installation.update")}
|
||||
</Button>
|
||||
);
|
||||
if (!updateUrl) return null;
|
||||
return (
|
||||
<Button variant="outline" onPress={handleOpenInstructions}>
|
||||
{t("settings.providers.installation.updateInstructions")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRunRow({
|
||||
action,
|
||||
updateUrl,
|
||||
}: {
|
||||
action: ProviderUpdateAction;
|
||||
updateUrl: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
const followOutput = useCallback(() => scroll.current?.scrollToEnd({ animated: false }), []);
|
||||
// A refused start replaces the previous update's result.
|
||||
const update = action.startError ? null : action.current;
|
||||
const status = update?.operation.status ?? "failed";
|
||||
let title = t("settings.providers.installation.updateFailed");
|
||||
if (status === "running") title = t("settings.providers.installation.updating");
|
||||
else if (status === "succeeded") title = t("settings.providers.installation.updated");
|
||||
let message = action.startError ?? undefined;
|
||||
if (update && update.operation.status !== "running") message = update.operation.message;
|
||||
const output = update?.output.map((entry) => entry.text).join("") ?? "";
|
||||
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-update-run">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.trailing}>
|
||||
{status === "running" ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<Text style={settingsStyles.rowTitle} testID="provider-update-status">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{message ? (
|
||||
<Text style={settingsStyles.rowHint} selectable testID="provider-update-message">
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
{output ? (
|
||||
<ScrollView
|
||||
ref={scroll}
|
||||
style={styles.output}
|
||||
contentContainerStyle={styles.outputContent}
|
||||
onContentSizeChange={followOutput}
|
||||
>
|
||||
{update?.truncated ? (
|
||||
<Text style={styles.outputNote}>
|
||||
{t("settings.providers.installation.outputTruncated")}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={styles.outputText}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-update-output"
|
||||
>
|
||||
{output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{status === "failed" && updateUrl ? (
|
||||
<View style={styles.link}>
|
||||
<ExternalLink
|
||||
href={updateUrl}
|
||||
label={t("settings.providers.installation.updateInstructions")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
output: {
|
||||
maxHeight: 240,
|
||||
marginTop: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
outputContent: {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
outputText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
outputNote: {
|
||||
marginBottom: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
version: {
|
||||
flexShrink: 1,
|
||||
marginLeft: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
availableVersion: {
|
||||
marginTop: theme.spacing[1],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
link: {
|
||||
flexDirection: "row",
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
ProviderInstallationState,
|
||||
ProviderUpdateAction,
|
||||
ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
export interface ProviderInstallationRead {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
/** Absent when the host cannot report provider installations. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
/** Ask the provider again, skipping the host's caches. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a provider's installation from hosts that advertise `providerInstallation`. The update
|
||||
* check is a separate request that starts once the provider reports itself installed, so the
|
||||
* installed version never waits on it.
|
||||
*/
|
||||
export function useProviderInstallation(input: {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
}): ProviderInstallationRead {
|
||||
const { serverId, provider, visible } = input;
|
||||
const supported = useHostFeature(serverId, "providerInstallation");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const enabled = supported && visible && Boolean(client);
|
||||
const installationKey = ["provider-installation", serverId, provider] as const;
|
||||
const updatesKey = ["provider-updates", serverId, provider] as const;
|
||||
|
||||
const inspect = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.inspectProviderInstallation(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.installation;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
const checkForUpdates = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.checkProviderUpdates(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.updateCheck;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
|
||||
// The host caches both answers, so reading on every open is cheap.
|
||||
const installationQuery = useFetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(false),
|
||||
enabled,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const installed = installationQuery.data?.inspection?.status === "installed";
|
||||
const updatesQuery = useFetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(false),
|
||||
enabled: enabled && installed,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
|
||||
// A refresh runs through the queries so its answer or its failure is what the screen shows.
|
||||
// `staleTime: 0` overrides the app default so the request always goes out.
|
||||
const refreshInstallation = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
const refreshUpdates = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
// The host owns the update; this view follows it while open and catches up on reopen.
|
||||
const [currentUpdate, setCurrentUpdate] = useState<ProviderInstallationUpdate | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled || !client) return;
|
||||
const subscription = client.observeProviderUpdates(provider);
|
||||
// The update this view saw running. Its success arrives as live progress, or as the snapshot
|
||||
// a reconnect delivers when the progress was missed; either way the host dropped its cached
|
||||
// answers, so read the new version and update state.
|
||||
let runningId: string | null = null;
|
||||
const follow = (operation: ProviderInstallationUpdate["operation"] | undefined) => {
|
||||
if (!operation) return;
|
||||
if (operation.status === "running") {
|
||||
runningId = operation.id;
|
||||
return;
|
||||
}
|
||||
if (operation.id !== runningId) return;
|
||||
runningId = null;
|
||||
if (operation.status !== "succeeded") return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["provider-installation", serverId, provider],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["provider-updates", serverId, provider] });
|
||||
};
|
||||
const detach = subscription.subscribe({
|
||||
snapshot: (response) => {
|
||||
setCurrentUpdate(response.update);
|
||||
follow(response.update?.operation);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "provider.installation.update.progress") return;
|
||||
setCurrentUpdate((current) => applyProviderUpdateProgress(current, message.payload));
|
||||
follow(message.payload.operation);
|
||||
},
|
||||
error: (failure) => console.warn("Failed to observe provider updates", failure),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
void subscription
|
||||
.release()
|
||||
.catch((failure) => console.warn("Failed to release provider update observation", failure));
|
||||
};
|
||||
}, [client, enabled, provider, queryClient, serverId]);
|
||||
const startUpdate = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.updateProvider(provider);
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.update;
|
||||
},
|
||||
onSuccess: (update) => {
|
||||
if (!update) return;
|
||||
// Progress for this operation may already have arrived; keep the further-along copy.
|
||||
setCurrentUpdate((current) =>
|
||||
current?.operation.id === update.operation.id ? current : update,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutate: mutateStartUpdate } = startUpdate;
|
||||
const start = useCallback(() => mutateStartUpdate(), [mutateStartUpdate]);
|
||||
const action: ProviderUpdateAction | undefined = supported
|
||||
? {
|
||||
current: currentUpdate,
|
||||
starting: startUpdate.isPending,
|
||||
startError: startUpdate.error?.message ?? null,
|
||||
start,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { mutate: mutateInstallation } = refreshInstallation;
|
||||
const { mutate: mutateUpdates } = refreshUpdates;
|
||||
const refresh = useCallback(() => {
|
||||
if (!supported || !client) return;
|
||||
mutateInstallation();
|
||||
if (installed) mutateUpdates();
|
||||
}, [client, installed, mutateInstallation, mutateUpdates, supported]);
|
||||
|
||||
let state: ProviderInstallationState;
|
||||
if (!supported) state = { status: "unsupported" };
|
||||
else if (installationQuery.isPending || refreshInstallation.isPending)
|
||||
state = { status: "loading" };
|
||||
else if (installationQuery.isError)
|
||||
state = { status: "error", message: installationQuery.error.message };
|
||||
else state = { status: "ready", installation: installationQuery.data ?? null };
|
||||
|
||||
let updates: ProviderUpdatesState;
|
||||
if (!supported || !installed) updates = { status: "hidden" };
|
||||
else if (updatesQuery.isPending || refreshUpdates.isPending) updates = { status: "loading" };
|
||||
else if (updatesQuery.isError) updates = { status: "error", message: updatesQuery.error.message };
|
||||
else if (updatesQuery.data) updates = { status: "ready", updateCheck: updatesQuery.data };
|
||||
else updates = { status: "hidden" };
|
||||
|
||||
return { state, updates, action, refresh };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderInstallationUpdateProgress } from "@getpaseo/protocol/messages";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
const running = {
|
||||
status: "running",
|
||||
id: "update-1",
|
||||
startedAt: "2026-11-13T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
function progress(...texts: string[]): ProviderInstallationUpdateProgress {
|
||||
return {
|
||||
provider: "codex",
|
||||
operation: running,
|
||||
output: texts.map((text) => ({ type: "stdout", text })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyProviderUpdateProgress", () => {
|
||||
it("appends output for the same update", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("a"), { outputLimit: 10 });
|
||||
const second = applyProviderUpdateProgress(first, progress("b"), { outputLimit: 10 });
|
||||
expect(second.output.map((entry) => entry.text)).toEqual(["a", "b"]);
|
||||
expect(second.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the oldest output beyond the limit and marks the update truncated", () => {
|
||||
let update = applyProviderUpdateProgress(null, progress("12345"), { outputLimit: 8 });
|
||||
update = applyProviderUpdateProgress(update, progress("6789"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["6789"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
|
||||
update = applyProviderUpdateProgress(update, progress("abcdefghij"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["cdefghij"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("starts a new update empty", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("123456789"), { outputLimit: 4 });
|
||||
const next = applyProviderUpdateProgress(
|
||||
first,
|
||||
{ ...progress("x"), operation: { ...running, id: "update-2" } },
|
||||
{ outputLimit: 4 },
|
||||
);
|
||||
expect(next.output.map((entry) => entry.text)).toEqual(["x"]);
|
||||
expect(next.truncated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateOutput,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
/** Characters of update output the app keeps, matching what the daemon retains. */
|
||||
export const PROVIDER_UPDATE_OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Folds a progress message into the update it belongs to; a new operation starts empty. Output
|
||||
* beyond `outputLimit` characters drops oldest first and marks the update truncated, so a noisy
|
||||
* updater cannot grow the screen's memory while it stays open.
|
||||
*/
|
||||
export function applyProviderUpdateProgress(
|
||||
current: ProviderInstallationUpdate | null,
|
||||
progress: ProviderInstallationUpdateProgress,
|
||||
options: { outputLimit: number } = { outputLimit: PROVIDER_UPDATE_OUTPUT_LIMIT },
|
||||
): ProviderInstallationUpdate {
|
||||
const previous = current?.operation.id === progress.operation.id ? current : null;
|
||||
const limit = options.outputLimit;
|
||||
const output: ProviderInstallationUpdateOutput[] = [
|
||||
...(previous?.output ?? []),
|
||||
...progress.output,
|
||||
];
|
||||
let length = output.reduce((total, entry) => total + entry.text.length, 0);
|
||||
let truncated = previous?.truncated ?? false;
|
||||
while (length > limit && output.length > 1) {
|
||||
length -= output.shift()!.text.length;
|
||||
truncated = true;
|
||||
}
|
||||
const last = output.at(-1);
|
||||
if (last && last.text.length > limit) {
|
||||
output[output.length - 1] = { type: last.type, text: last.text.slice(-limit) };
|
||||
truncated = true;
|
||||
}
|
||||
return { operation: progress.operation, output, ...(truncated ? { truncated: true } : {}) };
|
||||
}
|
||||
@@ -58,8 +58,6 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -67,7 +65,6 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -635,12 +632,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -658,37 +649,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,12 +10,6 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -33,10 +27,6 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -85,30 +75,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -186,7 +186,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -16,10 +16,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import {
|
||||
PluginWorkspaceRowBadgeItems,
|
||||
usePluginWorkspaceRowBadgeItems,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
@@ -63,9 +59,6 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*
|
||||
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
|
||||
* the row adds no separator, since only the plugin knows whether it has anything to say.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -74,7 +67,6 @@ export function WorkspaceMetaRow({
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
workspace = null,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
@@ -82,8 +74,6 @@ export function WorkspaceMetaRow({
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
/** The real workspace behind the row; previews leave it out and get no plugin items. */
|
||||
workspace?: { serverId: string; workspaceId: string } | null;
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -96,9 +86,8 @@ export function WorkspaceMetaRow({
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
|
||||
|
||||
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -108,9 +97,6 @@ export function WorkspaceMetaRow({
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
{workspace ? (
|
||||
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
@@ -10,27 +9,13 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
|
||||
// other workspace field changes, and a new context value re-renders every Markdown message.
|
||||
const webHost = forgeWeb?.host;
|
||||
const referenceRepository = forgeWeb?.referenceRepository;
|
||||
const value = useMemo(
|
||||
() =>
|
||||
createForgeShorthand({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
|
||||
}),
|
||||
[forge, remoteUrl, webHost, referenceRepository],
|
||||
);
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,17 +22,6 @@ describe("createForgeShorthand", () => {
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
|
||||
const fork = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
})!;
|
||||
|
||||
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
@@ -27,15 +26,12 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) =>
|
||||
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
|
||||
null,
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
@@ -45,7 +41,6 @@ export function createForgeShorthand(input: {
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
|
||||
@@ -326,81 +326,3 @@ describe("hasForgeWebUrls", () => {
|
||||
expect(hasForgeWebUrls("bitbucket")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links resolved by the daemon (forgeWeb)", () => {
|
||||
const sshAliasRemote = "git@github.com-work:acme/repo.git";
|
||||
|
||||
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb: { host: "github.com" },
|
||||
sigil: "#",
|
||||
number: 1,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/issues/1");
|
||||
});
|
||||
|
||||
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
|
||||
const forgeWeb = { host: "github.com" };
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
|
||||
).toBe("https://github.com/acme/repo/tree/main");
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb,
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
sigil: "#",
|
||||
number: 123,
|
||||
}),
|
||||
).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("keeps an explicit owner/repo#N reference on the named repository", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
repo: "other/tools",
|
||||
sigil: "#",
|
||||
number: 7,
|
||||
}),
|
||||
).toBe("https://github.com/other/tools/issues/7");
|
||||
});
|
||||
|
||||
it("keeps the branch and file links on the origin repository for a fork", () => {
|
||||
const input = {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
branch: "feature/only-on-fork",
|
||||
};
|
||||
expect(buildForgeBranchTreeUrl("github", input)).toBe(
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
);
|
||||
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scheme and port of a self-hosted http remote", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("gitea", {
|
||||
remoteUrl: "http://forge.test:3000/team/repo.git",
|
||||
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
|
||||
sigil: "#",
|
||||
number: 12,
|
||||
}),
|
||||
).toBe("http://forge.test:3000/upstream/repo/issues/12");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,23 +6,17 @@
|
||||
* format); an unknown forge has no grammar and yields null, so the action is
|
||||
* simply absent rather than wrong.
|
||||
*
|
||||
* URL grammar lives on each client forge module. The daemon resolves the web
|
||||
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
|
||||
* issue and change-request numbers (a fork's parent) and publishes them as
|
||||
* `forgeWeb`; branch and file links stay on the origin repository.
|
||||
* URL grammar lives on each client forge module. The repo identity and host
|
||||
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
||||
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
||||
*/
|
||||
import {
|
||||
forgeWebHost,
|
||||
isForgeCloudHost,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
@@ -31,17 +25,12 @@ export interface ForgeBlobUrlInput {
|
||||
|
||||
export interface ForgeBranchTreeUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ForgeReferenceUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
/**
|
||||
* Repository path of a cross-repository reference; the repository that owns
|
||||
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
|
||||
*/
|
||||
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
||||
repo?: string | null;
|
||||
sigil: ForgeReferenceSigil;
|
||||
number: number;
|
||||
@@ -73,6 +62,13 @@ interface ForgeWebLocation {
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host + repo path from a remote. The host is the remote's host, except a
|
||||
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
||||
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
||||
* Self-hosted hosts are returned untouched. The repo path supports nested
|
||||
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
||||
*/
|
||||
function isValidRepoPath(path: string): boolean {
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
@@ -81,17 +77,9 @@ function isValidRepoPath(path: string): boolean {
|
||||
return !segments.includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web location of the origin repository. The host is the daemon's
|
||||
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
|
||||
* own host is used, with a cloud SSH alias mapped to the forge's web host.
|
||||
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
|
||||
* the full remote path.
|
||||
*/
|
||||
function resolveForgeWebLocation(
|
||||
forge: string,
|
||||
remoteUrl: string | null | undefined,
|
||||
forgeWeb: ForgeWeb | null | undefined,
|
||||
): ForgeWebLocation | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -100,9 +88,9 @@ function resolveForgeWebLocation(
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
|
||||
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
|
||||
const isCloudHost = isForgeCloudHost(forge, location.host);
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
@@ -151,7 +139,7 @@ export function buildForgeBranchTreeUrl(
|
||||
input: ForgeBranchTreeUrlInput,
|
||||
): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
@@ -161,7 +149,7 @@ export function buildForgeBranchTreeUrl(
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeBlobPath(input.path);
|
||||
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
||||
@@ -187,8 +175,8 @@ export function buildForgeReferenceUrl(
|
||||
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
||||
(path) => path.sigil === input.sigil,
|
||||
);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const repo = input.repo ?? location?.repo;
|
||||
if (
|
||||
!referencePath ||
|
||||
!location ||
|
||||
|
||||
@@ -96,8 +96,6 @@ export interface AppSettings {
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
|
||||
sidebarPluginRowBadgeItems: Record<string, boolean>;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||||
@@ -158,7 +156,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarWorkspaceTrailing: "timestamp",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarPluginRowBadgeItems: {},
|
||||
sidebarNavItems: [],
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
@@ -256,7 +253,6 @@ const StoredAppSettingsSchema = z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
|
||||
@@ -3092,6 +3092,26 @@ export const ar: TranslationResources = {
|
||||
custom: "نماذج مخصصة",
|
||||
updated: "تم تحديث{{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "التثبيت",
|
||||
version: "الإصدار",
|
||||
installed: "مثبّت",
|
||||
notInstalled: "غير مثبّت",
|
||||
install: "تثبيت",
|
||||
checking: "جارٍ التحقق…",
|
||||
failed: "تعذّر التحقق من التثبيت",
|
||||
updates: "التحديثات",
|
||||
upToDate: "محدّث",
|
||||
updateAvailable: "يتوفر تحديث",
|
||||
updateInstructions: "تعليمات التحديث",
|
||||
update: "تحديث",
|
||||
updating: "جارٍ التحديث…",
|
||||
updated: "تم التحديث",
|
||||
updateFailed: "فشل التحديث",
|
||||
outputTruncated: "تم حذف المخرجات السابقة.",
|
||||
updatesFailed: "تعذّر التحقق من التحديثات",
|
||||
installInstructions: "تعليمات التثبيت",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "التشخيص",
|
||||
button: "التشخيص",
|
||||
|
||||
@@ -3187,6 +3187,26 @@ export const en = {
|
||||
custom: "Custom models",
|
||||
updated: "Updated {{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "Installation",
|
||||
version: "Version",
|
||||
installed: "Installed",
|
||||
notInstalled: "Not installed",
|
||||
install: "Install",
|
||||
checking: "Checking…",
|
||||
failed: "Couldn't check this installation",
|
||||
updates: "Updates",
|
||||
upToDate: "Up to date",
|
||||
updateAvailable: "Update available",
|
||||
updateInstructions: "Update instructions",
|
||||
update: "Update",
|
||||
updating: "Updating…",
|
||||
updated: "Updated",
|
||||
updateFailed: "Update failed",
|
||||
outputTruncated: "Earlier output was dropped.",
|
||||
updatesFailed: "Couldn't check for updates",
|
||||
installInstructions: "Install instructions",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "Diagnostic",
|
||||
button: "Diagnostic",
|
||||
|
||||
@@ -3121,6 +3121,26 @@ export const es: TranslationResources = {
|
||||
custom: "Modelos personalizados",
|
||||
updated: "{{time}}actualizado",
|
||||
},
|
||||
installation: {
|
||||
title: "Instalación",
|
||||
version: "Versión",
|
||||
installed: "Instalado",
|
||||
notInstalled: "No instalado",
|
||||
install: "Instalar",
|
||||
checking: "Comprobando…",
|
||||
failed: "No se pudo comprobar la instalación",
|
||||
updates: "Actualizaciones",
|
||||
upToDate: "Actualizado",
|
||||
updateAvailable: "Actualización disponible",
|
||||
updateInstructions: "Instrucciones de actualización",
|
||||
update: "Actualizar",
|
||||
updating: "Actualizando…",
|
||||
updated: "Actualizado",
|
||||
updateFailed: "No se pudo actualizar",
|
||||
outputTruncated: "Se descartó la salida anterior.",
|
||||
updatesFailed: "No se pudo comprobar si hay actualizaciones",
|
||||
installInstructions: "Instrucciones de instalación",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "Diagnóstico",
|
||||
button: "Diagnóstico",
|
||||
|
||||
@@ -3129,6 +3129,26 @@ export const fr: TranslationResources = {
|
||||
custom: "Modèles personnalisés",
|
||||
updated: "Mis à jour {{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "Installation",
|
||||
version: "Version",
|
||||
installed: "Installé",
|
||||
notInstalled: "Non installé",
|
||||
install: "Installer",
|
||||
checking: "Vérification…",
|
||||
failed: "Impossible de vérifier l'installation",
|
||||
updates: "Mises à jour",
|
||||
upToDate: "À jour",
|
||||
updateAvailable: "Mise à jour disponible",
|
||||
updateInstructions: "Instructions de mise à jour",
|
||||
update: "Mettre à jour",
|
||||
updating: "Mise à jour…",
|
||||
updated: "Mis à jour",
|
||||
updateFailed: "Échec de la mise à jour",
|
||||
outputTruncated: "La sortie précédente a été supprimée.",
|
||||
updatesFailed: "Impossible de vérifier les mises à jour",
|
||||
installInstructions: "Instructions d'installation",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "Diagnostic",
|
||||
button: "Diagnostic",
|
||||
|
||||
@@ -3089,6 +3089,26 @@ export const ja: TranslationResources = {
|
||||
custom: "カスタムモデル",
|
||||
updated: "{{time}}に更新",
|
||||
},
|
||||
installation: {
|
||||
title: "インストール",
|
||||
version: "バージョン",
|
||||
installed: "インストール済み",
|
||||
notInstalled: "未インストール",
|
||||
install: "インストール",
|
||||
checking: "確認中…",
|
||||
failed: "インストールを確認できませんでした",
|
||||
updates: "アップデート",
|
||||
upToDate: "最新です",
|
||||
updateAvailable: "アップデートがあります",
|
||||
updateInstructions: "アップデート手順",
|
||||
update: "アップデート",
|
||||
updating: "アップデート中…",
|
||||
updated: "アップデート済み",
|
||||
updateFailed: "アップデートに失敗しました",
|
||||
outputTruncated: "以前の出力は破棄されました。",
|
||||
updatesFailed: "アップデートを確認できませんでした",
|
||||
installInstructions: "インストール手順",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "診断",
|
||||
button: "診断",
|
||||
|
||||
@@ -3075,6 +3075,26 @@ export const ko: TranslationResources = {
|
||||
custom: "사용자 지정 모델",
|
||||
updated: "{{time}} 업데이트됨",
|
||||
},
|
||||
installation: {
|
||||
title: "설치",
|
||||
version: "버전",
|
||||
installed: "설치됨",
|
||||
notInstalled: "설치되지 않음",
|
||||
install: "설치",
|
||||
checking: "확인 중…",
|
||||
failed: "설치를 확인할 수 없습니다",
|
||||
updates: "업데이트",
|
||||
upToDate: "최신 상태",
|
||||
updateAvailable: "업데이트 가능",
|
||||
updateInstructions: "업데이트 안내",
|
||||
update: "업데이트",
|
||||
updating: "업데이트 중…",
|
||||
updated: "업데이트됨",
|
||||
updateFailed: "업데이트 실패",
|
||||
outputTruncated: "이전 출력이 삭제되었습니다.",
|
||||
updatesFailed: "업데이트를 확인할 수 없습니다",
|
||||
installInstructions: "설치 안내",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "진단",
|
||||
button: "진단",
|
||||
|
||||
@@ -3102,6 +3102,26 @@ export const ptBR: TranslationResources = {
|
||||
custom: "Modelos personalizados",
|
||||
updated: "Atualizado {{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "Instalação",
|
||||
version: "Versão",
|
||||
installed: "Instalado",
|
||||
notInstalled: "Não instalado",
|
||||
install: "Instalar",
|
||||
checking: "Verificando…",
|
||||
failed: "Não foi possível verificar a instalação",
|
||||
updates: "Atualizações",
|
||||
upToDate: "Atualizado",
|
||||
updateAvailable: "Atualização disponível",
|
||||
updateInstructions: "Instruções de atualização",
|
||||
update: "Atualizar",
|
||||
updating: "Atualizando…",
|
||||
updated: "Atualizado",
|
||||
updateFailed: "Falha na atualização",
|
||||
outputTruncated: "A saída anterior foi descartada.",
|
||||
updatesFailed: "Não foi possível verificar atualizações",
|
||||
installInstructions: "Instruções de instalação",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "Diagnóstico",
|
||||
button: "Diagnóstico",
|
||||
|
||||
@@ -3113,6 +3113,26 @@ export const ru: TranslationResources = {
|
||||
custom: "Пользовательские модели",
|
||||
updated: "Обновлено {{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "Установка",
|
||||
version: "Версия",
|
||||
installed: "Установлено",
|
||||
notInstalled: "Не установлено",
|
||||
install: "Установить",
|
||||
checking: "Проверка…",
|
||||
failed: "Не удалось проверить установку",
|
||||
updates: "Обновления",
|
||||
upToDate: "Актуальная версия",
|
||||
updateAvailable: "Доступно обновление",
|
||||
updateInstructions: "Инструкции по обновлению",
|
||||
update: "Обновить",
|
||||
updating: "Обновление…",
|
||||
updated: "Обновлено",
|
||||
updateFailed: "Не удалось обновить",
|
||||
outputTruncated: "Более ранний вывод был отброшен.",
|
||||
updatesFailed: "Не удалось проверить обновления",
|
||||
installInstructions: "Инструкции по установке",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "Диагностика",
|
||||
button: "Диагностика",
|
||||
|
||||
@@ -3021,6 +3021,26 @@ export const zhCN: TranslationResources = {
|
||||
custom: "自定义 Models",
|
||||
updated: "已更新 {{time}}",
|
||||
},
|
||||
installation: {
|
||||
title: "安装",
|
||||
version: "版本",
|
||||
installed: "已安装",
|
||||
notInstalled: "未安装",
|
||||
install: "安装",
|
||||
checking: "正在检查…",
|
||||
failed: "无法检查安装状态",
|
||||
updates: "更新",
|
||||
upToDate: "已是最新",
|
||||
updateAvailable: "有可用更新",
|
||||
updateInstructions: "更新说明",
|
||||
update: "更新",
|
||||
updating: "正在更新…",
|
||||
updated: "已更新",
|
||||
updateFailed: "更新失败",
|
||||
outputTruncated: "较早的输出已被丢弃。",
|
||||
updatesFailed: "无法检查更新",
|
||||
installInstructions: "安装说明",
|
||||
},
|
||||
diagnostic: {
|
||||
title: "诊断",
|
||||
button: "诊断",
|
||||
|
||||
@@ -4,32 +4,20 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
|
||||
|
||||
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
|
||||
const surfaces = new Map<symbol, KeyboardFocusSurface>();
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
declare(surface: KeyboardFocusSurface) {
|
||||
const token = Symbol(surface);
|
||||
surfaces.set(token, surface);
|
||||
notify();
|
||||
return () => {
|
||||
if (surfaces.delete(token)) notify();
|
||||
surfaces.delete(token);
|
||||
};
|
||||
},
|
||||
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
|
||||
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
|
||||
for (const surface of surfaces.values()) focused = surface;
|
||||
return focused;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface KeyboardSourceHandlers {
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
subscribeCommandCenter(listener: () => void): () => void;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
@@ -24,6 +23,5 @@ export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -366,13 +366,7 @@ describe("Electron guest source through shared resolution", () => {
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
@@ -19,9 +19,6 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
|
||||
// so there is no character to take from it.
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
@@ -20,9 +19,6 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
|
||||
module.setClaimedShortcuts(shortcuts);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -3,19 +3,12 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
|
||||
isComposing: boolean;
|
||||
phase: "down" | "up";
|
||||
}
|
||||
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
|
||||
export interface ClaimedShortcut {
|
||||
code: string;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
export function subscribeHardwareKeyboard(
|
||||
_handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
return () => {};
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
|
||||
import {
|
||||
setHardwareKeyboardClaimedShortcuts,
|
||||
setHardwareKeyboardOverlayKeys,
|
||||
subscribeHardwareKeyboard,
|
||||
} from "./hardware";
|
||||
import { createHardwareKeyboardSource } from "./native";
|
||||
const hardwareKeyboard = createHardwareKeyboardSource({
|
||||
subscribe: subscribeHardwareKeyboard,
|
||||
setOverlayKeys: setHardwareKeyboardOverlayKeys,
|
||||
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
|
||||
});
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return hardwareKeyboard.attach(handlers);
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
|
||||
hardwareKeyboard.publishPolicy(input);
|
||||
}
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -25,7 +26,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy(input)
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,31 @@
|
||||
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import {
|
||||
nativeKeyboardInput,
|
||||
type KeyboardPolicyInput,
|
||||
type KeyboardSourceHandlers,
|
||||
} from "./contract";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
}
|
||||
|
||||
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
|
||||
|
||||
/**
|
||||
* The shortcuts a focused text control would otherwise type or act on.
|
||||
*
|
||||
* The native window observes every press but cannot wait for the pipeline to
|
||||
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
|
||||
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
|
||||
* combos do not type, and the platform's own editing combos stay with the text
|
||||
* control through `editable: false`. Unmodified keys belong to overlays and the
|
||||
* composer's Return.
|
||||
*/
|
||||
function claimedShortcuts(
|
||||
policy: KeyboardPolicyInput | null,
|
||||
commandCenterOpen: boolean,
|
||||
): ClaimedShortcut[] {
|
||||
if (!policy?.enabled) return [];
|
||||
const context = {
|
||||
isMac: policy.isMac,
|
||||
usesDesktopBindings: policy.usesDesktopBindings,
|
||||
commandCenterOpen,
|
||||
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
|
||||
};
|
||||
const claimed = new Map<string, ClaimedShortcut>();
|
||||
for (const binding of policy.bindings) {
|
||||
const combo = binding.parsedChord[0];
|
||||
if (!combo || binding.preventDefault === false) continue;
|
||||
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
|
||||
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
|
||||
const shift = combo.shift === true;
|
||||
const alt = combo.alt === true;
|
||||
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
|
||||
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
|
||||
export function attachHardwareKeyboardSource(
|
||||
source: HardwareKeySource,
|
||||
handlers: KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateKeys();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return [...claimed.values()];
|
||||
}
|
||||
|
||||
export function createHardwareKeyboardSource(source: HardwareKeySource) {
|
||||
let policy: KeyboardPolicyInput | null = null;
|
||||
let updateClaims = () => {};
|
||||
return {
|
||||
publishPolicy(next: KeyboardPolicyInput) {
|
||||
policy = next;
|
||||
updateClaims();
|
||||
},
|
||||
attach(handlers: KeyboardSourceHandlers): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateClaims = () =>
|
||||
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
|
||||
updateKeys();
|
||||
updateClaims();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
|
||||
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeFocus();
|
||||
unsubscribeCommandCenter();
|
||||
unsubscribeOverlays();
|
||||
updateClaims = () => {};
|
||||
source.setOverlayKeys([]);
|
||||
source.setClaimedShortcuts([]);
|
||||
};
|
||||
},
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ describe("DOM source adapter", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
|
||||
it.each([true, false])(
|
||||
"delivers hardware down/up and declared focus with mac=%s",
|
||||
async (isMac) => {
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
@@ -148,22 +148,23 @@ describe("hardware source adapter", () => {
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = createHardwareKeyboardSource({
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts() {},
|
||||
}).attach({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
@@ -179,94 +180,4 @@ describe("hardware source adapter", () => {
|
||||
expect(events.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
let claimed: string[] = [];
|
||||
const source = createHardwareKeyboardSource({
|
||||
subscribe: () => () => {},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts(shortcuts) {
|
||||
claimed = shortcuts
|
||||
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
|
||||
.map((parts) => parts.filter(Boolean).join("+"))
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
let commandCenterOpen = false;
|
||||
const commandCenterListeners = new Set<() => void>();
|
||||
const detach = source.attach({
|
||||
keyDown() {},
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => commandCenterOpen,
|
||||
subscribeCommandCenter(listener) {
|
||||
commandCenterListeners.add(listener);
|
||||
return () => commandCenterListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
const setCommandCenterOpen = (open: boolean) => {
|
||||
commandCenterOpen = open;
|
||||
for (const listener of commandCenterListeners) listener();
|
||||
};
|
||||
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
const releaseComposer = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
setCommandCenterOpen(true);
|
||||
expect(claimed).toEqual([]);
|
||||
setCommandCenterOpen(false);
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
releaseComposer();
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseRebound = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Alt+KeyM"]);
|
||||
releaseRebound();
|
||||
|
||||
// A chord's later step is only a shortcut while the chord is in progress;
|
||||
// claiming it always would stop the composer typing a capital A.
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseChord = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual([]);
|
||||
releaseChord();
|
||||
|
||||
const releaseFinal = focusSurfaces.declare("message-input");
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(claimed).toEqual([]);
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
detach();
|
||||
expect(claimed).toEqual([]);
|
||||
releaseFinal();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
@@ -265,10 +265,6 @@ export function useKeyboardShortcuts({
|
||||
keyUp: handleKeyUp,
|
||||
blur: resetModifiers,
|
||||
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
subscribeCommandCenter: (listener) =>
|
||||
useKeyboardShortcutsStore.subscribe((state, previous) => {
|
||||
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
|
||||
@@ -134,7 +134,6 @@ describe("DOM source through the shared resolver", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -26,7 +26,6 @@ function installation(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -105,7 +105,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,7 +37,6 @@ function installed(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -47,7 +47,6 @@ function plugin(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -101,7 +101,6 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
|
||||
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
|
||||
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
|
||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
||||
@@ -133,7 +132,6 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -154,7 +152,6 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -285,44 +282,6 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Sidebar item main has no title");
|
||||
});
|
||||
|
||||
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
);
|
||||
expect(
|
||||
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
|
||||
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
|
||||
|
||||
const row = (fields: string) =>
|
||||
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate workspace row badge item: status");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status has no title");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status is not a component");
|
||||
});
|
||||
|
||||
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
PluginScreenContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspacePanelContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
} from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
@@ -134,7 +133,6 @@ export function runPluginClientBundle(
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
@@ -153,7 +151,6 @@ export function runPluginClientBundle(
|
||||
header: new Set(),
|
||||
footer: new Set(),
|
||||
};
|
||||
const workspaceRowItemIds = new Set<string>();
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const newWorkspaceItemIds = new Set<string>();
|
||||
@@ -326,23 +323,6 @@ export function runPluginClientBundle(
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("footer", contribution);
|
||||
},
|
||||
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
|
||||
const itemId = requireId(contribution.id, "workspace row badge item id");
|
||||
if (workspaceRowItemIds.has(itemId))
|
||||
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
|
||||
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Workspace row badge item ${itemId} is not a component`);
|
||||
}
|
||||
workspaceRowItemIds.add(itemId);
|
||||
return register(
|
||||
collector.workspaceRowBadgeItems,
|
||||
{ id: itemId, title, icon, Component: contribution.Component },
|
||||
() => workspaceRowItemIds.delete(itemId),
|
||||
);
|
||||
},
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
@@ -654,7 +634,6 @@ export function runPluginClientBundle(
|
||||
sidebarViews: collector.sidebarViews,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
newWorkspaceItems: collector.newWorkspaceItems,
|
||||
|
||||
@@ -46,7 +46,6 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
|
||||
/** `withUnistyles` mapping that hands a host the plugin theme. */
|
||||
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
export function renderNothing() {
|
||||
function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
export { ExternalLink } from "@/components/ui/external-link";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
|
||||
|
||||
export { DocumentFrame } from "@/file-panels/document-frame";
|
||||
|
||||
|
||||
@@ -125,7 +125,6 @@ export class PluginRegistry {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,7 +37,6 @@ function installed(
|
||||
footer: [{ id: "status", title: "Status", Component: () => null }],
|
||||
},
|
||||
legacySidebarItems,
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -51,7 +51,6 @@ function installation(
|
||||
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
workspaceRowBadgeItems: [],
|
||||
legacySidebarItems: sidebarItems.map((item) => ({
|
||||
...item,
|
||||
title: item.id,
|
||||
|
||||
@@ -64,7 +64,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -27,7 +27,6 @@ function plugin(input: {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -34,7 +34,6 @@ vi.mock("../client-runtime", () => ({
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
PluginIconView: () => null,
|
||||
pluginIconComponent: () => () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution as SDKPluginScreenContribution,
|
||||
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
@@ -34,8 +33,6 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
|
||||
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
|
||||
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
|
||||
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
|
||||
type PluginWorkspaceRowBadgeItemContribution =
|
||||
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
|
||||
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
|
||||
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
|
||||
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
|
||||
@@ -59,7 +56,6 @@ export interface EvaluatedPlugin {
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
|
||||
@@ -99,7 +95,6 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginThemeContribution,
|
||||
|
||||
@@ -24,7 +24,6 @@ function installed(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
|
||||
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
|
||||
|
||||
import { WorkspaceRowBadgeTitleContext } from "./kit";
|
||||
|
||||
export { WorkspaceRowBadge } from "./kit";
|
||||
export {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "./model";
|
||||
|
||||
interface RowBadgeItemTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginWorkspaceRowBadgeItemContribution;
|
||||
}
|
||||
|
||||
/** The visible plugin row items installed on this workspace's host. */
|
||||
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
|
||||
const installed = useInstalledPlugins();
|
||||
const {
|
||||
settings: { sidebarPluginRowBadgeItems },
|
||||
} = useAppSettings();
|
||||
return useMemo(
|
||||
() =>
|
||||
installed
|
||||
.filter((plugin) => plugin.serverId === serverId)
|
||||
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
|
||||
.filter(({ plugin, item }) =>
|
||||
isWorkspaceRowBadgeItemVisible(
|
||||
sidebarPluginRowBadgeItems,
|
||||
workspaceRowBadgeItemKey(plugin.id, item.id),
|
||||
),
|
||||
),
|
||||
[installed, serverId, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
|
||||
* renders nothing must leave no gap behind it on the line.
|
||||
*/
|
||||
export function PluginWorkspaceRowBadgeItems({
|
||||
targets,
|
||||
workspaceId,
|
||||
}: {
|
||||
targets: readonly RowBadgeItemTarget[];
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return targets.map((target) => (
|
||||
<ThemedRowBadgeItemHost
|
||||
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
|
||||
target={target}
|
||||
workspaceId={workspaceId}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function RowBadgeItemHost({
|
||||
target: { plugin, item },
|
||||
workspaceId,
|
||||
theme,
|
||||
}: {
|
||||
target: RowBadgeItemTarget;
|
||||
workspaceId: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
|
||||
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
|
||||
() => ({ ...hostProps, workspaceId }),
|
||||
[hostProps, workspaceId],
|
||||
);
|
||||
const title = plugin.i18n.resolve(item.title, plugin.locale);
|
||||
const Item = item.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<WorkspaceRowBadgeTitleContext.Provider value={title}>
|
||||
<Item {...props} />
|
||||
</WorkspaceRowBadgeTitleContext.Provider>
|
||||
</PluginEnvironmentProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { parsePluginIcon, PluginIconView } from "../icons";
|
||||
|
||||
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
|
||||
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
|
||||
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
|
||||
if (tone === "success") return theme.colors.statusSuccess;
|
||||
if (tone === "warning") return theme.colors.statusWarning;
|
||||
if (tone === "danger") return theme.colors.statusDanger;
|
||||
if (tone === "info") return theme.colors.statusDotRunning;
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
|
||||
muted: (theme) => ({ color: toneColor(theme, "muted") }),
|
||||
success: (theme) => ({ color: toneColor(theme, "success") }),
|
||||
warning: (theme) => ({ color: toneColor(theme, "warning") }),
|
||||
danger: (theme) => ({ color: toneColor(theme, "danger") }),
|
||||
info: (theme) => ({ color: toneColor(theme, "info") }),
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
|
||||
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
|
||||
* slot, so the badge draws no separator.
|
||||
*/
|
||||
export function WorkspaceRowBadge({
|
||||
icon,
|
||||
label,
|
||||
tone = "muted",
|
||||
accessibilityLabel,
|
||||
}: WorkspaceRowBadgeProps) {
|
||||
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
|
||||
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
|
||||
const parsedIcon = useMemo(
|
||||
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
|
||||
[icon],
|
||||
);
|
||||
if (!parsedIcon && !label) return null;
|
||||
return (
|
||||
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
|
||||
{parsedIcon ? (
|
||||
<ThemedPluginIconView
|
||||
icon={parsedIcon}
|
||||
size={HOST_BADGE_ICON_SIZE}
|
||||
uniProps={TONE_MAPPINGS[tone]}
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Read inside render, never into a module-scope table — see docs/unistyles.md.
|
||||
function textToneStyle(tone: WorkspaceRowBadgeTone) {
|
||||
if (tone === "success") return styles.success;
|
||||
if (tone === "warning") return styles.warning;
|
||||
if (tone === "danger") return styles.danger;
|
||||
if (tone === "info") return styles.info;
|
||||
return styles.muted;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 16,
|
||||
flexShrink: 1,
|
||||
},
|
||||
muted: { color: toneColor(theme, "muted") },
|
||||
success: { color: toneColor(theme, "success") },
|
||||
warning: { color: toneColor(theme, "warning") },
|
||||
danger: { color: toneColor(theme, "danger") },
|
||||
info: { color: toneColor(theme, "info") },
|
||||
}));
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
|
||||
|
||||
function Item() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
|
||||
return {
|
||||
id,
|
||||
serverId,
|
||||
locale: "en",
|
||||
i18n: { resolve: (text: string) => text },
|
||||
workspaceRowBadgeItems: itemIds.map((itemId) => ({
|
||||
id: itemId,
|
||||
title: `${id} ${itemId}`,
|
||||
icon: "Gauge",
|
||||
Component: Item,
|
||||
})),
|
||||
} as unknown as InstalledPlugin;
|
||||
}
|
||||
|
||||
describe("groupPluginWorkspaceRowBadgeItems", () => {
|
||||
it("lists each plugin item once across hosts, in install order", () => {
|
||||
const groups = groupPluginWorkspaceRowBadgeItems([
|
||||
installed("status", "host-a", ["state"]),
|
||||
installed("asks", "host-a", ["open"]),
|
||||
installed("status", "host-b", ["state"]),
|
||||
]);
|
||||
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
|
||||
{ key: "status/state", title: "status state" },
|
||||
{ key: "asks/open", title: "asks open" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkspaceRowBadgeItemVisible", () => {
|
||||
it("shows items until they are switched off", () => {
|
||||
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
/** One workspace row badge item, coalesced across every host that contributes it. */
|
||||
export interface PluginWorkspaceRowBadgeItemGroup {
|
||||
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
|
||||
key: string;
|
||||
pluginId: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
icon: PluginIcon;
|
||||
}
|
||||
|
||||
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
|
||||
return `${pluginId}/${itemId}`;
|
||||
}
|
||||
|
||||
/** An item missing from the stored record has never been toggled, so it shows. */
|
||||
export function isWorkspaceRowBadgeItemVisible(
|
||||
visibility: Readonly<Record<string, boolean>>,
|
||||
key: string,
|
||||
): boolean {
|
||||
return visibility[key] !== false;
|
||||
}
|
||||
|
||||
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
|
||||
export function groupPluginWorkspaceRowBadgeItems(
|
||||
plugins: readonly InstalledPlugin[],
|
||||
): PluginWorkspaceRowBadgeItemGroup[] {
|
||||
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
|
||||
for (const plugin of plugins) {
|
||||
for (const item of plugin.workspaceRowBadgeItems) {
|
||||
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
|
||||
if (groups.has(key)) continue;
|
||||
groups.set(key, {
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
itemId: item.id,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
icon: item.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -354,21 +354,6 @@ describe("ReplicaCache", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves where the workspace's forge is on the web across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
|
||||
const forgeWeb = { host: "github.com", referenceRepository: "main-owner/repo" };
|
||||
const workspace = normalizeWorkspaceDescriptor({ ...workspacePayload(), forgeWeb });
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.workspaces.get(workspace.id)?.forgeWeb).toEqual(forgeWeb);
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
||||
import {
|
||||
AgentStatusSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
ForgeWebSchema,
|
||||
WorkspaceGitHubRuntimePayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
@@ -336,7 +335,6 @@ const StoredWorkspaceSchema = z.strictObject({
|
||||
gitRuntime: WorkspaceGitRuntimeSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
forge: z.string().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const StoredProjectSchema = z.strictObject({
|
||||
@@ -770,7 +768,6 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
gitRuntime: workspace.gitRuntime,
|
||||
githubRuntime: workspace.githubRuntime,
|
||||
forge: workspace.forge,
|
||||
forgeWeb: workspace.forgeWeb,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3919,11 +3919,7 @@ function WorkspaceMarkdownLinks({
|
||||
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
|
||||
return (
|
||||
<AssistantFileLinkResolverProvider {...fileLinks}>
|
||||
<ForgeShorthandProvider
|
||||
forge={workspace?.forge}
|
||||
remoteUrl={workspace?.gitRuntime?.remoteUrl}
|
||||
forgeWeb={workspace?.forgeWeb}
|
||||
>
|
||||
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
|
||||
{children}
|
||||
</ForgeShorthandProvider>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
|
||||
@@ -140,7 +140,6 @@ export interface WorkspaceDescriptor {
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
forge?: WorkspaceDescriptorPayload["forge"];
|
||||
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
@@ -186,7 +185,6 @@ export function normalizeWorkspaceDescriptor(
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
forge: payload.forge,
|
||||
forgeWeb: payload.forgeWeb,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,13 +2,7 @@ import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type {
|
||||
AvailableUsageReport,
|
||||
UsageReport,
|
||||
UsageReportEntry,
|
||||
UsageView,
|
||||
UsageWindow,
|
||||
} from "./types";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
|
||||
export function usedPercent(window: UsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
@@ -211,34 +205,13 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
|
||||
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
||||
}
|
||||
|
||||
export interface UsageSnapshot {
|
||||
report: AvailableUsageReport;
|
||||
successfulAt: string;
|
||||
retained: boolean;
|
||||
}
|
||||
|
||||
/** The latest successful values and their freshness, independent of the latest attempt's outcome. */
|
||||
export function resolveUsageSnapshot(entry: UsageReportEntry): UsageSnapshot | null {
|
||||
if (entry.report.status === "available") {
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Old daemons expose only attempt time for available reports, never for failures.
|
||||
return {
|
||||
report: entry.report,
|
||||
successfulAt: entry.successfulAt ?? entry.fetchedAt,
|
||||
retained: false,
|
||||
};
|
||||
}
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
const retained = entry.lastSuccessful;
|
||||
if (!retained) return null;
|
||||
return { report: retained.report, successfulAt: retained.fetchedAt, retained: true };
|
||||
}
|
||||
|
||||
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
||||
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
const available = entry.report.status === "available";
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
const snapshot = available ? null : (entry.lastSuccessful ?? null);
|
||||
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
|
||||
const messages: string[] = [];
|
||||
if (!available) {
|
||||
if (entry.loginErrors)
|
||||
@@ -251,12 +224,16 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
else messages.push(usageProblemText(entry.report, t));
|
||||
}
|
||||
return {
|
||||
windows: snapshot?.report.windows ?? [],
|
||||
balances: snapshot?.report.balances ?? [],
|
||||
details: snapshot?.report.details ?? [],
|
||||
planLabel: snapshot?.report.planLabel ?? null,
|
||||
successfulAt: snapshot?.successfulAt ?? null,
|
||||
retained: snapshot?.retained ?? false,
|
||||
windows: content?.windows ?? [],
|
||||
balances: content?.balances ?? [],
|
||||
details: content?.details ?? [],
|
||||
planLabel: content?.planLabel ?? null,
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Old daemons expose only attempt time for available reports, never for failures.
|
||||
successfulAt: available
|
||||
? (entry.successfulAt ?? entry.fetchedAt)
|
||||
: (snapshot?.fetchedAt ?? null),
|
||||
retained: snapshot !== null,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ describe("resolvePinnedUsage", () => {
|
||||
expect(sources[0]).toEqual({
|
||||
key: "claude:default",
|
||||
icon: "<svg/>",
|
||||
hasError: false,
|
||||
windows: [
|
||||
{
|
||||
key: "claude:default/five-hour",
|
||||
@@ -258,7 +257,7 @@ describe("resolvePinnedUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retained windows and marks errored sources in the sidebar summary", () => {
|
||||
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
|
||||
if (claude.report.status !== "available" || codex.report.status !== "available")
|
||||
throw new Error("fixtures must be available");
|
||||
const expired: UsageReportEntry = {
|
||||
@@ -274,30 +273,7 @@ it("shows retained windows and marks errored sources in the sidebar summary", ()
|
||||
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
|
||||
report: { status: "error", error: "Store deleted" },
|
||||
};
|
||||
const expected = [
|
||||
{ key: "claude:default", hasError: false, windows: ["31%"] },
|
||||
{ key: "codex:default", hasError: true, windows: ["12%"] },
|
||||
];
|
||||
const summarize = (pins: UsagePreferences["pins"]) =>
|
||||
resolvePinnedUsage([expired, failed], preferences(pins)).map((source) => ({
|
||||
key: source.key,
|
||||
hasError: source.hasError,
|
||||
windows: source.windows.map((window) => window.percentText),
|
||||
}));
|
||||
|
||||
expect(summarize(null)).toEqual(expected);
|
||||
expect(
|
||||
summarize([
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(
|
||||
resolvePinnedUsage(
|
||||
[{ ...failed, lastSuccessful: undefined }],
|
||||
preferences([{ sourceId: "codex", windowId: "weekly" }]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
|
||||
});
|
||||
|
||||
it("localizes pinned labels and meanings while preserving custom window names", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
|
||||
import { displayPercent, resolveUsageSnapshot } from "./model";
|
||||
import { displayPercent } from "./model";
|
||||
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageReportEntry, UsageTone } from "./types";
|
||||
@@ -23,8 +23,6 @@ export interface PinnedUsageWindow {
|
||||
export interface PinnedUsageSource {
|
||||
key: string;
|
||||
icon: string | null;
|
||||
/** The displayed values are retained from before a failed refresh. */
|
||||
hasError: boolean;
|
||||
windows: PinnedUsageWindow[];
|
||||
}
|
||||
|
||||
@@ -76,9 +74,8 @@ export function resolvePinnedUsage(
|
||||
): PinnedUsageSource[] {
|
||||
const pins = effectiveUsagePins(preferences, reports);
|
||||
return groupBySource(reports).flatMap((entry) => {
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
if (!snapshot) return [];
|
||||
const windows = snapshot.report.windows
|
||||
if (entry.report.status !== "available") return [];
|
||||
const windows = entry.report.windows
|
||||
.filter(
|
||||
(window) =>
|
||||
displayPercent(window, preferences.displayAs) !== null &&
|
||||
@@ -97,13 +94,6 @@ export function resolvePinnedUsage(
|
||||
};
|
||||
});
|
||||
if (windows.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: entry.id,
|
||||
icon: entry.icon ?? null,
|
||||
hasError: entry.report.status === "error",
|
||||
windows,
|
||||
},
|
||||
];
|
||||
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { displayPercent, resolveUsageSnapshot } from "./model";
|
||||
import { displayPercent } from "./model";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** Whether percentages read as the share used or the share left. */
|
||||
@@ -55,9 +55,8 @@ export function effectiveUsagePins(
|
||||
if (preferences.pins !== null) return preferences.pins;
|
||||
const pins: UsagePin[] = [];
|
||||
for (const entry of reports) {
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
if (!snapshot) continue;
|
||||
const withPercent = snapshot.report.windows.filter(
|
||||
if (entry.report.status !== "available") continue;
|
||||
const withPercent = entry.report.windows.filter(
|
||||
(window) => displayPercent(window, preferences.displayAs) !== null,
|
||||
);
|
||||
const marked = withPercent.filter((window) => window.summary);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
@@ -16,8 +15,7 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
@@ -37,9 +35,6 @@ import {
|
||||
} from "./pinned";
|
||||
import { UsageModal } from "./usage-modal";
|
||||
|
||||
const ThemedTriangleAlert = withUnistyles(TriangleAlert);
|
||||
const errorIconColor = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
export function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
@@ -65,14 +60,7 @@ export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSou
|
||||
const label = t(builtinSidebarNavLabelKey("usage"));
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<PinnedUsageTrigger
|
||||
label={label}
|
||||
sources={sources}
|
||||
errorLabel={t("providerUsage.refreshFailed")}
|
||||
onPress={openUsage}
|
||||
/>
|
||||
);
|
||||
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
|
||||
}
|
||||
|
||||
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
|
||||
@@ -107,15 +95,9 @@ export function useOpenSidebarUsage(): () => void {
|
||||
return openUsage;
|
||||
}
|
||||
|
||||
function pinnedUsageLabel(
|
||||
label: string,
|
||||
sources: readonly PinnedUsageSource[],
|
||||
errorLabel: string,
|
||||
): string {
|
||||
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
||||
const windows = sources.flatMap((source) => source.windows);
|
||||
const summary = windows.map((window) => window.label).join(", ");
|
||||
const error = sources.some((source) => source.hasError) ? `, ${errorLabel}` : "";
|
||||
return `${label}: ${summary}${error}`;
|
||||
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
@@ -134,12 +116,10 @@ function layoutWidth(event: LayoutChangeEvent): number {
|
||||
function PinnedUsageTrigger({
|
||||
label,
|
||||
sources,
|
||||
errorLabel,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
sources: readonly PinnedUsageSource[];
|
||||
errorLabel: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const [available, setAvailable] = useState<number | null>(null);
|
||||
@@ -164,7 +144,7 @@ function PinnedUsageTrigger({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources)}
|
||||
style={triggerStyle}
|
||||
testID="sidebar-usage"
|
||||
>
|
||||
@@ -205,17 +185,10 @@ function PinnedUsageLine({
|
||||
return sources.map((source, sourceIndex) => (
|
||||
<Fragment key={source.key}>
|
||||
<View
|
||||
style={[styles.source, sourceIndex === 0 ? null : styles.sourceGap]}
|
||||
style={sourceIndex === 0 ? null : styles.sourceGap}
|
||||
testID={measuring ? undefined : "sidebar-usage-source"}
|
||||
>
|
||||
<UsageSourceIcon svg={source.icon} size={14} />
|
||||
{source.hasError ? (
|
||||
<ThemedTriangleAlert
|
||||
size={12}
|
||||
uniProps={errorIconColor}
|
||||
testID={measuring ? undefined : "sidebar-usage-error"}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{source.windows.map((window, windowIndex) => {
|
||||
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
|
||||
@@ -278,11 +251,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
source: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sourceGap: {
|
||||
marginLeft: theme.spacing[3],
|
||||
},
|
||||
|
||||
@@ -184,55 +184,6 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the branch and the active file on the web host the daemon resolved for an SSH alias", () => {
|
||||
const aliasStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com-work:acme/repo.git",
|
||||
currentBranch: "feature/x",
|
||||
forgeWeb: { host: "github.com" },
|
||||
};
|
||||
const open = (activeFile?: { path: string }) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: aliasStatus,
|
||||
forge: "github",
|
||||
}).map((target) => (target.source === "forge" ? target.url : null));
|
||||
|
||||
expect(open()).toEqual(["https://github.com/acme/repo/tree/feature/x"]);
|
||||
expect(open({ path: "src/app.ts" })).toEqual([
|
||||
"https://github.com/acme/repo/blob/feature/x/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the branch and file on the origin repository of a fork", () => {
|
||||
const forkStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
currentBranch: "feature/only-on-fork",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
};
|
||||
const forgeUrls = (activeFile: { path: string } | undefined) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: forkStatus,
|
||||
forge: "github",
|
||||
}).flatMap((target) => (target.source === "forge" ? [target.url] : []));
|
||||
const urls = [...forgeUrls(undefined), ...forgeUrls({ path: "src/app.ts" })];
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers the forge from the remote URL when the forge input is null", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { type Forge, forgeFromRemoteUrl, getForgePresentation } from "@/git/forge";
|
||||
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
|
||||
import {
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
interface CheckoutStatusForOpenTarget {
|
||||
isGit: boolean;
|
||||
remoteUrl?: string | null;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
currentBranch?: string | null;
|
||||
}
|
||||
|
||||
@@ -120,7 +118,6 @@ function buildForgeWebUrl(
|
||||
forge: Forge,
|
||||
input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
path: string | null;
|
||||
lineStart?: number;
|
||||
@@ -132,7 +129,6 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBlobUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
path: input.path,
|
||||
lineStart: input.lineStart,
|
||||
@@ -143,7 +139,6 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBranchTreeUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
}) ?? null
|
||||
);
|
||||
@@ -164,7 +159,6 @@ function planForgeOpenTarget(input: {
|
||||
}
|
||||
const url = buildForgeWebUrl(forge, {
|
||||
remoteUrl: input.checkoutStatus.remoteUrl,
|
||||
forgeWeb: input.checkoutStatus.forgeWeb,
|
||||
branch: input.checkoutStatus.currentBranch,
|
||||
path: input.resolvedFile?.relativePath ?? null,
|
||||
lineStart: input.activeFile?.lineStart,
|
||||
|
||||
@@ -117,6 +117,8 @@ import type {
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderInstallationInspectResponseMessage,
|
||||
ProviderInstallationCheckUpdatesResponseMessage,
|
||||
UsageReportEntry,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
@@ -629,6 +631,13 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
type ProviderInstallationInspectPayload = ProviderInstallationInspectResponseMessage["payload"];
|
||||
type ProviderInstallationCheckUpdatesPayload =
|
||||
ProviderInstallationCheckUpdatesResponseMessage["payload"];
|
||||
type ProviderInstallationUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "provider.installation.update.response" }
|
||||
>["payload"];
|
||||
interface UsageListReportsPayload {
|
||||
requestId: string;
|
||||
reports: UsageReportEntry[];
|
||||
@@ -6224,6 +6233,69 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Install and update links plus installed state. Requires `features.providerInstallation`. */
|
||||
async inspectProviderInstallation(
|
||||
provider: AgentProvider,
|
||||
options?: { refresh?: boolean; requestId?: string },
|
||||
): Promise<ProviderInstallationInspectPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.inspect.response">({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.installation.inspect.request",
|
||||
provider,
|
||||
...(options?.refresh ? { refresh: true } : {}),
|
||||
},
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the provider reports a newer version. Requires `features.providerInstallation`. */
|
||||
async checkProviderUpdates(
|
||||
provider: AgentProvider,
|
||||
options?: { refresh?: boolean; requestId?: string },
|
||||
): Promise<ProviderInstallationCheckUpdatesPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.check_updates.response">(
|
||||
{
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.installation.check_updates.request",
|
||||
provider,
|
||||
...(options?.refresh ? { refresh: true } : {}),
|
||||
},
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts the provider's update, or joins the running one. Follow it with `observeProviderUpdates`. */
|
||||
async updateProvider(
|
||||
provider: AgentProvider,
|
||||
options?: { requestId?: string },
|
||||
): Promise<ProviderInstallationUpdatePayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.update.response">({
|
||||
requestId: options?.requestId,
|
||||
message: { type: "provider.installation.update.request", provider },
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's latest update, then `provider.installation.update.progress` as updates start,
|
||||
* report output and settle. Survives reconnects: each new snapshot replaces the previous one.
|
||||
*/
|
||||
observeProviderUpdates(
|
||||
provider: AgentProvider,
|
||||
options?: { signal?: AbortSignal },
|
||||
): OwnedSubscription<
|
||||
CorrelatedResponsePayload<"provider.installation.update.subscribe.response">
|
||||
> {
|
||||
return this.observe(
|
||||
"provider.installation.update.subscribe.response",
|
||||
{ type: "provider.installation.update.subscribe.request", provider },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async listProviderUsage(options?: { requestId?: string }): Promise<ProviderUsageListPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
|
||||
@@ -306,11 +306,6 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
/**
|
||||
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
|
||||
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
|
||||
*/
|
||||
wholeTurns?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,6 @@ describe("lifecycle hook SDK types", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.kind === "checkout-branch") {
|
||||
@@ -427,17 +426,8 @@ describe("lifecycle hook SDK types", () => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
|
||||
const { agent, outcome } = event;
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
|
||||
event.timeline;
|
||||
const turn = await paseo.agents
|
||||
.ref(agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items: readonly AgentTimelineItem[] = turn.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item);
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
outcome.request;
|
||||
});
|
||||
|
||||
@@ -121,22 +121,6 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
|
||||
Component: ComponentType<PluginSidebarViewProps>;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
|
||||
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
|
||||
*/
|
||||
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
/** The item's toggle in the sidebar's Show menu. */
|
||||
title: Text;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
||||
* and color; buttons widen the props with their host and target context.
|
||||
@@ -230,9 +214,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
|
||||
addWorkspaceRowBadgeItem(
|
||||
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
|
||||
): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
|
||||
addCommandCenterItem(
|
||||
contribution: PluginCommandCenterItemContribution<PluginText>,
|
||||
|
||||
@@ -18,8 +18,6 @@ export type {
|
||||
PluginSidebarViewProps,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemProps,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
|
||||
@@ -173,19 +173,6 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
|
||||
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
|
||||
export declare const SidebarSeparator: ComponentType;
|
||||
|
||||
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
|
||||
export interface WorkspaceRowBadgeProps {
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
label?: string;
|
||||
/** Default "muted". */
|
||||
tone?: WorkspaceRowBadgeTone;
|
||||
/** Defaults to `label`, then the item's `title`. */
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
|
||||
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
|
||||
|
||||
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
|
||||
export declare const Loading: ComponentType;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentToolDefinition } from "./agent-tools.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -103,13 +104,12 @@ export interface PluginHookEvents {
|
||||
"agent.turn.after": {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
/**
|
||||
* The payload carries no history. Read the turn with
|
||||
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
|
||||
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
|
||||
* started before the read, or when `turnId` is `null`.
|
||||
*/
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
};
|
||||
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
"agent.permission.after": {
|
||||
|
||||
@@ -39,9 +39,86 @@ export interface ProviderRegistration {
|
||||
description?: string;
|
||||
/** Plugin-directory-relative path to a self-contained SVG file. */
|
||||
icon?: string;
|
||||
/** How users install the provider's CLI and which version they run. Every member is optional. */
|
||||
installation?: ProviderInstallation;
|
||||
connect(request: ProviderConnectRequest): Promise<ProviderConnection>;
|
||||
}
|
||||
|
||||
export interface ProviderInstallation {
|
||||
/** http(s) page that explains how to install the provider. Paseo opens it; it never installs. */
|
||||
installUrl?: string;
|
||||
/** http(s) page that explains how to update the provider. */
|
||||
updateUrl?: string;
|
||||
/** Report whether the provider is installed and which version. Runs only when a user asks. */
|
||||
inspect?(context: ProviderInstallationContext): Promise<ProviderInspection>;
|
||||
/**
|
||||
* Report whether a newer version is available. The provider decides how to find out and what
|
||||
* counts as newer; Paseo shows the result as written. Return `unknown` when it cannot tell.
|
||||
* Runs only when a user asks, never before a session.
|
||||
*/
|
||||
checkForUpdates?(context: ProviderInstallationContext): Promise<ProviderUpdateCheck>;
|
||||
/**
|
||||
* Update the provider, usually by running its CLI's own updater. Paseo shows everything passed
|
||||
* to `report` while it runs, then the returned message. A throw is a failure with its message.
|
||||
* Runs only when a user asks. Paseo sets no deadline; `signal` aborts when the daemon stops.
|
||||
*/
|
||||
update?(context: ProviderUpdateContext): Promise<ProviderUpdateResult>;
|
||||
}
|
||||
|
||||
export interface ProviderUpdateContext extends ProviderInstallationContext {
|
||||
/** Shows output to the user as it happens, unchanged and in order. */
|
||||
report(event: ProviderUpdateReport): void;
|
||||
}
|
||||
|
||||
export const ProviderUpdateReportSchema = z
|
||||
.object({ type: z.enum(["stdout", "stderr", "message"]), text: z.string() })
|
||||
.strip();
|
||||
|
||||
export type ProviderUpdateReport = z.infer<typeof ProviderUpdateReportSchema>;
|
||||
|
||||
export const ProviderUpdateResultSchema = z.discriminatedUnion("status", [
|
||||
/** `message` is shown as written, such as which version is now installed. */
|
||||
z.object({ status: z.literal("succeeded"), message: z.string().optional() }).strip(),
|
||||
z.object({ status: z.literal("failed"), message: z.string() }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderUpdateResult = z.infer<typeof ProviderUpdateResultSchema>;
|
||||
|
||||
export interface ProviderInstallationContext {
|
||||
/** The launch a session would use. Absent when the provider declares no `command`. */
|
||||
launch?: ProviderInstallationLaunch;
|
||||
/** Aborted when Paseo stops waiting for the result. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ProviderInstallationLaunch extends ProviderLaunch {
|
||||
/** False when the executable was not found; `command` is then the configured command as written. */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export const ProviderInspectionSchema = z.discriminatedUnion("status", [
|
||||
/** `version` is shown as written; Paseo does not parse or compare it. */
|
||||
z.object({ status: z.literal("installed"), version: z.string().optional() }).strip(),
|
||||
z.object({ status: z.literal("not_installed") }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderInspection = z.infer<typeof ProviderInspectionSchema>;
|
||||
|
||||
export const ProviderUpdateCheckSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("current") }).strip(),
|
||||
/** `versions` and `message` are shown as written; Paseo does not parse, sort or compare them. */
|
||||
z
|
||||
.object({
|
||||
status: z.literal("available"),
|
||||
versions: z.array(z.string()).optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.strip(),
|
||||
z.object({ status: z.literal("unknown"), message: z.string().optional() }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderUpdateCheck = z.infer<typeof ProviderUpdateCheckSchema>;
|
||||
|
||||
export type ProviderCatalogOptions =
|
||||
| { scope: "global"; force?: boolean; launch?: ProviderLaunch }
|
||||
| { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch };
|
||||
|
||||
@@ -108,26 +108,6 @@ export function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** Whether `host` is one of the forge's public cloud hosts, SSH aliases included. */
|
||||
export function isForgeCloudHost(forge: string, host: string): boolean {
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
return cloudHosts.includes(normalizeHost(host));
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host for a remote host of `forge`. A cloud SSH alias (a non-first entry
|
||||
* in the forge's `cloudHosts`, e.g. ssh.github.com) maps to the forge's web
|
||||
* host (`cloudHosts[0]`); any other host, self-hosted included, is returned
|
||||
* as is.
|
||||
*/
|
||||
export function forgeWebHost(forge: string, host: string): string {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!isForgeCloudHost(forge, normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeHost(getForgeDefinition(forge)?.cloudHosts?.[0] ?? normalized);
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
|
||||
@@ -1958,6 +1958,34 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationInspectRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.inspect.request"),
|
||||
provider: AgentProviderSchema,
|
||||
/** Skip the daemon's short result cache and ask the provider again. */
|
||||
refresh: z.boolean().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationCheckUpdatesRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.check_updates.request"),
|
||||
provider: AgentProviderSchema,
|
||||
/** Skip the daemon's update-check cache and ask the provider again. */
|
||||
refresh: z.boolean().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.request"),
|
||||
provider: AgentProviderSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateSubscribeRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.subscribe.request"),
|
||||
provider: AgentProviderSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.request"),
|
||||
requestId: z.string(),
|
||||
@@ -3592,6 +3620,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
ProviderDiagnosticRequestMessageSchema,
|
||||
ProviderInstallationInspectRequestMessageSchema,
|
||||
ProviderInstallationCheckUpdatesRequestMessageSchema,
|
||||
ProviderInstallationUpdateRequestMessageSchema,
|
||||
ProviderInstallationUpdateSubscribeRequestMessageSchema,
|
||||
ProviderUsageListRequestMessageSchema,
|
||||
UsageListReportsRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
@@ -3918,6 +3950,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
|
||||
usageSetting: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
// COMPAT(providerInstallation): added in v0.17.0, remove gate after 2027-05-13.
|
||||
providerInstallation: z.boolean().optional(),
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: z.boolean().optional(),
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
@@ -4328,22 +4362,6 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
terminalId: z.string().nullable().optional().default(null),
|
||||
});
|
||||
|
||||
/**
|
||||
* Where a workspace's forge is on the web, resolved by the daemon so every
|
||||
* client builds the same links.
|
||||
*/
|
||||
export const ForgeWebSchema = z.object({
|
||||
/** Web host of the forge: the SSH config alias resolved, cloud SSH hosts mapped to their web host. */
|
||||
host: z.string(),
|
||||
/**
|
||||
* Repository path that owns issue and change-request numbers when it is not
|
||||
* the origin repository, such as a fork's parent. Absent: the origin repository.
|
||||
*/
|
||||
referenceRepository: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ForgeWeb = z.infer<typeof ForgeWebSchema>;
|
||||
|
||||
const WorkspaceGitRuntimePayloadSchema = z
|
||||
.object({
|
||||
currentBranch: z.string().nullable().optional(),
|
||||
@@ -4480,8 +4498,6 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
// until 2027-01-17; remove the consumer fallback once the supported daemon
|
||||
// floor is >= v0.2.0.
|
||||
forge: z.string().optional(),
|
||||
// Absent from daemons older than v0.16.0; clients then derive links from the origin remote.
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
project: ProjectPlacementPayloadSchema.optional(),
|
||||
// COMPAT(directorySync): sequence of this latest directory projection.
|
||||
syncSeq: z.number().int().positive().optional(),
|
||||
@@ -5715,7 +5731,6 @@ const CheckoutStatusCommonSchema = z.object({
|
||||
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
|
||||
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
|
||||
upstreamRef: z.string().nullable().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
||||
@@ -6742,6 +6757,129 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/** Provider-owned facts; `version` is an opaque string the app shows as written. */
|
||||
export const ProviderInstallationInspectionSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("installed"), version: z.string().optional() }),
|
||||
z.object({ status: z.literal("not_installed") }),
|
||||
z.object({ status: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const ProviderInstallationSchema = z.object({
|
||||
installUrl: z.string().optional(),
|
||||
updateUrl: z.string().optional(),
|
||||
/** Absent when the provider does not report its installation. */
|
||||
inspection: ProviderInstallationInspectionSchema.optional(),
|
||||
/** True when the provider can run its own update through `provider.installation.update`. */
|
||||
canUpdate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationInspectResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.inspect.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** Null when the provider declares no installation contract. */
|
||||
installation: ProviderInstallationSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The provider's answer to "is a newer version available". `versions` and `message` are opaque
|
||||
* strings the app shows as written. `error` means the check threw or missed its deadline.
|
||||
*/
|
||||
export const ProviderInstallationUpdateCheckSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("current") }),
|
||||
z.object({
|
||||
status: z.literal("available"),
|
||||
versions: z.array(z.string()).optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
z.object({ status: z.literal("unknown"), message: z.string().optional() }),
|
||||
z.object({ status: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const ProviderInstallationCheckUpdatesResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.check_updates.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** Null when the provider cannot check for updates. */
|
||||
updateCheck: ProviderInstallationUpdateCheckSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** One piece of updater output, shown unchanged and in order. */
|
||||
export const ProviderInstallationUpdateOutputSchema = z.object({
|
||||
type: z.enum(["stdout", "stderr", "message"]),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
/** The state of one provider update. Messages are the provider's, shown as written. */
|
||||
export const ProviderInstallationUpdateOperationSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("running"), id: z.string(), startedAt: z.string() }),
|
||||
z.object({
|
||||
status: z.literal("succeeded"),
|
||||
id: z.string(),
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
id: z.string(),
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The latest update of a provider since the daemon started, with its retained output. */
|
||||
export const ProviderInstallationUpdateSchema = z.object({
|
||||
operation: ProviderInstallationUpdateOperationSchema,
|
||||
output: z.array(ProviderInstallationUpdateOutputSchema),
|
||||
/** True when the oldest output was dropped to stay within the daemon's output limit. */
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** The update this request started, or the one already running. */
|
||||
update: ProviderInstallationUpdateSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateSubscribeResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.subscribe.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
subscriptionId: z.string().optional(),
|
||||
/** Null when the provider has not been updated since the daemon started. */
|
||||
update: ProviderInstallationUpdateSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sent to `provider.installation.update.subscribe` owners when an update starts, reports output
|
||||
* or finishes. `output` holds only the entries new since the previous progress for `operation.id`.
|
||||
*/
|
||||
export const ProviderInstallationUpdateProgressMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.progress"),
|
||||
payload: z.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
provider: AgentProviderSchema,
|
||||
operation: ProviderInstallationUpdateOperationSchema,
|
||||
output: z.array(ProviderInstallationUpdateOutputSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
|
||||
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
|
||||
|
||||
@@ -7595,6 +7733,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
RefreshProvidersSnapshotResponseMessageSchema,
|
||||
ProviderDiagnosticResponseMessageSchema,
|
||||
ProviderInstallationInspectResponseMessageSchema,
|
||||
ProviderInstallationCheckUpdatesResponseMessageSchema,
|
||||
ProviderInstallationUpdateResponseMessageSchema,
|
||||
ProviderInstallationUpdateSubscribeResponseMessageSchema,
|
||||
ProviderInstallationUpdateProgressMessageSchema,
|
||||
ProviderUsageListResponseMessageSchema,
|
||||
UsageListReportsUpdateMessageSchema,
|
||||
UsageListReportsResponseMessageSchema,
|
||||
@@ -7779,6 +7922,26 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
|
||||
export type ProviderDiagnosticResponseMessage = z.infer<
|
||||
typeof ProviderDiagnosticResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallation = z.infer<typeof ProviderInstallationSchema>;
|
||||
export type ProviderInstallationInspection = z.infer<typeof ProviderInstallationInspectionSchema>;
|
||||
export type ProviderInstallationInspectResponseMessage = z.infer<
|
||||
typeof ProviderInstallationInspectResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateCheck = z.infer<typeof ProviderInstallationUpdateCheckSchema>;
|
||||
export type ProviderInstallationCheckUpdatesResponseMessage = z.infer<
|
||||
typeof ProviderInstallationCheckUpdatesResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateOutput = z.infer<
|
||||
typeof ProviderInstallationUpdateOutputSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateOperation = z.infer<
|
||||
typeof ProviderInstallationUpdateOperationSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdate = z.infer<typeof ProviderInstallationUpdateSchema>;
|
||||
export type ProviderInstallationUpdateProgress = Omit<
|
||||
z.infer<typeof ProviderInstallationUpdateProgressMessageSchema>["payload"],
|
||||
"subscriptionId"
|
||||
>;
|
||||
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
|
||||
export type UsageProblem = z.infer<typeof UsageProblemSchema>;
|
||||
export type UsageReport = z.infer<typeof UsageReportSchema>;
|
||||
|
||||
@@ -1251,15 +1251,6 @@ describe("workspace message schemas", () => {
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
|
||||
).toBe("gitlab");
|
||||
|
||||
// forgeWeb is optional: an old daemon omits it, a new daemon's value round-trips.
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forgeWeb).toBeUndefined();
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({
|
||||
...baseWorkspace,
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
}).forgeWeb,
|
||||
).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
|
||||
});
|
||||
|
||||
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
|
||||
|
||||
@@ -13334,6 +13334,7 @@ test.each([
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
ProviderInspection,
|
||||
ProviderUpdateCheck,
|
||||
ProviderUpdateReport,
|
||||
ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
@@ -804,6 +810,24 @@ export interface AgentDraftControls {
|
||||
options: ProviderOptions;
|
||||
}
|
||||
|
||||
export interface AgentClientInstallationContext {
|
||||
/** Aborted when Paseo stops waiting for the result. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface AgentClientInstallation {
|
||||
installUrl?: string;
|
||||
updateUrl?: string;
|
||||
inspect?(context: AgentClientInstallationContext): Promise<ProviderInspection>;
|
||||
checkForUpdates?(context: AgentClientInstallationContext): Promise<ProviderUpdateCheck>;
|
||||
/** Runs the provider's updater. See `ProviderInstallation.update` in the plugin contract. */
|
||||
update?(context: AgentClientUpdateContext): Promise<ProviderUpdateResult>;
|
||||
}
|
||||
|
||||
export interface AgentClientUpdateContext extends AgentClientInstallationContext {
|
||||
report(event: ProviderUpdateReport): void;
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
@@ -867,6 +891,12 @@ export interface AgentClient {
|
||||
*/
|
||||
isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise<boolean>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
/**
|
||||
* Installation links and the installed version. The same contract as plugin providers'
|
||||
* `ProviderRegistration.installation`; built-ins resolve their own launch inside `inspect`.
|
||||
* Never consulted by availability, discovery, or session creation.
|
||||
*/
|
||||
readonly installation?: AgentClientInstallation;
|
||||
/**
|
||||
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
|
||||
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
ProviderInputSchema,
|
||||
ProviderStatusSchema,
|
||||
type ProviderStatus,
|
||||
type ProviderInspection,
|
||||
type ProviderUpdateCheck,
|
||||
type ProviderUpdateResult,
|
||||
type ProviderInstallationLaunch,
|
||||
type ProviderLaunch,
|
||||
requireProviderCapabilities,
|
||||
type ProviderCapability,
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentClientInstallation,
|
||||
AgentClientUpdateContext,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
@@ -156,7 +162,8 @@ class ProviderRuntime {
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
||||
) {}
|
||||
|
||||
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
|
||||
/** The effective launch, including when its executable does not resolve. */
|
||||
private async resolveEffectiveLaunch(): Promise<ProviderInstallationLaunch | undefined> {
|
||||
const command = this.registration.command;
|
||||
if (!command) return undefined;
|
||||
const launch = await resolveProviderLaunch({
|
||||
@@ -167,8 +174,45 @@ class ProviderRuntime {
|
||||
const env = createProviderEnv({ runtimeSettings: this.runtimeSettings });
|
||||
launch.env = env;
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`);
|
||||
return { command: resolvePath(availability.resolvedPath), args: launch.args, env };
|
||||
if (!availability.resolvedPath)
|
||||
return { command: launch.command, args: launch.args, env, resolved: false };
|
||||
return {
|
||||
command: resolvePath(availability.resolvedPath),
|
||||
args: launch.args,
|
||||
env,
|
||||
resolved: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
if (!launch) return undefined;
|
||||
if (!launch.resolved) throw new Error(`${launch.command} not found on PATH`);
|
||||
return { command: launch.command, args: launch.args, env: launch.env };
|
||||
}
|
||||
|
||||
async inspectInstallation(signal: AbortSignal): Promise<ProviderInspection> {
|
||||
const inspect = this.registration.installation?.inspect;
|
||||
if (!inspect) throw new Error("Provider does not report its installation");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
signal.throwIfAborted();
|
||||
return await inspect.call(this.registration.installation, { launch, signal });
|
||||
}
|
||||
|
||||
async checkForUpdates(signal: AbortSignal): Promise<ProviderUpdateCheck> {
|
||||
const checkForUpdates = this.registration.installation?.checkForUpdates;
|
||||
if (!checkForUpdates) throw new Error("Provider does not check for updates");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
signal.throwIfAborted();
|
||||
return await checkForUpdates.call(this.registration.installation, { launch, signal });
|
||||
}
|
||||
|
||||
async update(context: AgentClientUpdateContext): Promise<ProviderUpdateResult> {
|
||||
const update = this.registration.installation?.update;
|
||||
if (!update) throw new Error("Provider does not update itself");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
context.signal.throwIfAborted();
|
||||
return await update.call(this.registration.installation, { ...context, launch });
|
||||
}
|
||||
|
||||
async status(): Promise<ProviderStatus> {
|
||||
@@ -961,12 +1005,26 @@ class PluginAgentClient implements AgentClient {
|
||||
private readonly pendingChildren: PendingChild[] = [];
|
||||
|
||||
readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"];
|
||||
readonly installation?: AgentClientInstallation;
|
||||
|
||||
constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.provider = registration.id;
|
||||
this.runtime = new ProviderRuntime(registration, runtimeSettings);
|
||||
if (registration.getCatalogCacheKey)
|
||||
this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options);
|
||||
const installation = registration.installation;
|
||||
if (installation)
|
||||
this.installation = {
|
||||
installUrl: installation.installUrl,
|
||||
updateUrl: installation.updateUrl,
|
||||
inspect: installation.inspect
|
||||
? ({ signal }) => this.runtime.inspectInstallation(signal)
|
||||
: undefined,
|
||||
checkForUpdates: installation.checkForUpdates
|
||||
? ({ signal }) => this.runtime.checkForUpdates(signal)
|
||||
: undefined,
|
||||
update: installation.update ? (context) => this.runtime.update(context) : undefined,
|
||||
};
|
||||
this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened));
|
||||
}
|
||||
|
||||
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
import type {
|
||||
ProviderEvent,
|
||||
ProviderInstallation,
|
||||
ProviderInstallationContext,
|
||||
ProviderRegistration,
|
||||
ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentClient, AgentClientInstallation } from "../agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import { ProviderInstallations } from "./provider-installations.js";
|
||||
|
||||
const OPAQUE_VERSION = " v1.2.3-beta+weird (build 7)\n";
|
||||
const MISSING_COMMAND = "paseo-provider-installation-missing-binary";
|
||||
const OPAQUE_VERSIONS = ["v9-nightly+abc", " 2.0 (LTS)\n"];
|
||||
const OPAQUE_MESSAGE = " Restart to finish updating\n";
|
||||
|
||||
function pluginProvider(
|
||||
installation: ProviderInstallation | undefined,
|
||||
command: readonly [string, ...string[]] = [process.execPath],
|
||||
): ProviderRegistration {
|
||||
return {
|
||||
id: "installation-plugin",
|
||||
label: "Installation plugin",
|
||||
command,
|
||||
installation,
|
||||
async connect() {
|
||||
let listener: ((event: ProviderEvent) => void) | undefined;
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
async send(input) {
|
||||
if (input.type === "catalog")
|
||||
listener?.({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: { models: [{ id: "model", label: "Model" }], modes: [] },
|
||||
});
|
||||
},
|
||||
onEvent(next) {
|
||||
listener = next;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function builtinClient(installation: AgentClientInstallation | undefined): AgentClient {
|
||||
return {
|
||||
provider: "codex",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
installation,
|
||||
async createSession() {
|
||||
throw new Error("not opened by this test");
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("not opened by this test");
|
||||
},
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
},
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managerWith(input: { plugin?: ProviderRegistration; codex?: AgentClient }) {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: input.codex ? { codex: input.codex } : {},
|
||||
});
|
||||
if (input.plugin) manager.replacePluginProviders([input.plugin]);
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe("provider installation", () => {
|
||||
test("exposes links and lets the plugin report not installed when its executable is missing", async () => {
|
||||
const contexts: ProviderInstallationContext[] = [];
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider(
|
||||
{
|
||||
installUrl: "https://example.com/install",
|
||||
updateUrl: "https://example.com/update",
|
||||
async inspect(context) {
|
||||
contexts.push(context);
|
||||
return context.launch?.resolved ? { status: "installed" } : { status: "not_installed" };
|
||||
},
|
||||
},
|
||||
[MISSING_COMMAND, "serve"],
|
||||
),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
updateUrl: "https://example.com/update",
|
||||
inspection: { status: "not_installed" },
|
||||
});
|
||||
expect(contexts[0]?.launch).toEqual({
|
||||
command: MISSING_COMMAND,
|
||||
args: ["serve"],
|
||||
env: expect.objectContaining({ PATH: expect.any(String) }),
|
||||
resolved: false,
|
||||
});
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "plugin",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
plugin: pluginProvider({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect({ launch }) {
|
||||
if (!launch?.resolved || launch.command !== process.execPath)
|
||||
throw new Error("Unexpected launch");
|
||||
return { status: "installed", version: OPAQUE_VERSION };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "installation-plugin",
|
||||
},
|
||||
{
|
||||
name: "built-in",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
codex: builtinClient({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect() {
|
||||
return { status: "installed", version: OPAQUE_VERSION };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "codex",
|
||||
},
|
||||
])("returns a $name provider's version string unchanged", async ({ manager, provider }) => {
|
||||
const providers = manager();
|
||||
try {
|
||||
await expect(providers.installations.inspect(provider)).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
inspection: { status: "installed", version: OPAQUE_VERSION },
|
||||
});
|
||||
} finally {
|
||||
providers.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps links and reports the failure when inspect throws", async () => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect() {
|
||||
throw new Error("version probe exploded");
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
inspection: { status: "error", message: "version probe exploded" },
|
||||
});
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for a provider without an installation contract", async () => {
|
||||
const manager = managerWith({ plugin: pluginProvider(undefined) });
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toBeNull();
|
||||
await expect(manager.installations.inspect("not-a-provider")).rejects.toThrow(
|
||||
"Provider not-a-provider is not configured",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("availability and discovery never wait on inspect", async () => {
|
||||
let inspectCalls = 0;
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
inspect() {
|
||||
inspectCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
void manager.installations.inspect("installation-plugin");
|
||||
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
|
||||
expect(entry.status).toBe("ready");
|
||||
expect(inspectCalls).toBe(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "plugin",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
plugin: pluginProvider({
|
||||
async checkForUpdates({ launch }) {
|
||||
if (!launch?.resolved) throw new Error("Unexpected launch");
|
||||
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "installation-plugin",
|
||||
},
|
||||
{
|
||||
name: "built-in",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
codex: builtinClient({
|
||||
async checkForUpdates() {
|
||||
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "codex",
|
||||
},
|
||||
])("returns a $name provider's available versions unchanged", async ({ manager, provider }) => {
|
||||
const providers = manager();
|
||||
try {
|
||||
await expect(providers.installations.checkForUpdates(provider)).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: OPAQUE_VERSIONS,
|
||||
message: OPAQUE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
providers.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "returns unknown",
|
||||
checkForUpdates: async () => ({ status: "unknown" as const, message: "Offline" }),
|
||||
expected: { status: "unknown", message: "Offline" },
|
||||
},
|
||||
{
|
||||
name: "throws",
|
||||
checkForUpdates: async (): Promise<never> => {
|
||||
throw new Error("release feed exploded");
|
||||
},
|
||||
expected: { status: "error", message: "release feed exploded" },
|
||||
},
|
||||
{
|
||||
name: "breaks the contract",
|
||||
checkForUpdates: async () => ({ status: "up_to_date" }) as never,
|
||||
expected: { status: "error", message: expect.stringContaining("status") },
|
||||
},
|
||||
])("reports an inconclusive check when the provider $name", async (input) => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({ checkForUpdates: input.checkForUpdates }),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.checkForUpdates("installation-plugin")).resolves.toEqual(
|
||||
input.expected,
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when the provider cannot check for updates", async () => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({ installUrl: "https://example.com/install" }),
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
manager.installations.checkForUpdates("installation-plugin"),
|
||||
).resolves.toBeNull();
|
||||
await expect(manager.installations.checkForUpdates("not-a-provider")).rejects.toThrow(
|
||||
"Provider not-a-provider is not configured",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("availability, discovery and the installed version never wait on an update check", async () => {
|
||||
let checks = 0;
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
async inspect() {
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
checkForUpdates() {
|
||||
checks += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
void manager.installations.checkForUpdates("installation-plugin");
|
||||
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
|
||||
expect(entry.status).toBe("ready");
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
});
|
||||
expect(checks).toBe(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderInstallations", () => {
|
||||
function counting(
|
||||
inspect: NonNullable<AgentClientInstallation["inspect"]>,
|
||||
options: { now?: () => number; inspectTimeoutMs?: number } = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
inspect(context) {
|
||||
calls += 1;
|
||||
return inspect(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
...options,
|
||||
});
|
||||
return { installations, calls: () => calls };
|
||||
}
|
||||
|
||||
test("shares concurrent reads, caches briefly, and refresh asks again", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = counting(
|
||||
async () => ({ status: "installed", version: `v${time}` }),
|
||||
{ now: () => time },
|
||||
);
|
||||
const [first, second] = await Promise.all([
|
||||
installations.inspect("codex"),
|
||||
installations.inspect("codex"),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
time = 1_000;
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "v0" },
|
||||
});
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
await expect(installations.inspect("codex", { refresh: true })).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "v1000" },
|
||||
});
|
||||
expect(calls()).toBe(2);
|
||||
|
||||
time = 1_000_000;
|
||||
await installations.inspect("codex");
|
||||
expect(calls()).toBe(3);
|
||||
});
|
||||
|
||||
function countingUpdates(
|
||||
checkForUpdates: NonNullable<AgentClientInstallation["checkForUpdates"]>,
|
||||
options: { now?: () => number; updateCheckTimeoutMs?: number } = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
checkForUpdates(context) {
|
||||
calls += 1;
|
||||
return checkForUpdates(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
updateCheckCacheMs: 3_600_000,
|
||||
inspectCacheMs: 30_000,
|
||||
...options,
|
||||
});
|
||||
return { installations, calls: () => calls };
|
||||
}
|
||||
|
||||
test("shares concurrent update checks, holds an answer for the long cache, and refresh asks again", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = countingUpdates(
|
||||
async () => ({ status: "available", versions: [`v${time}`] }),
|
||||
{ now: () => time },
|
||||
);
|
||||
const [first, second] = await Promise.all([
|
||||
installations.checkForUpdates("codex"),
|
||||
installations.checkForUpdates("codex"),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
time = 3_000_000;
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: ["v0"],
|
||||
});
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: ["v3000000"],
|
||||
});
|
||||
expect(calls()).toBe(2);
|
||||
|
||||
time = 3_000_000 + 3_600_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(3);
|
||||
});
|
||||
|
||||
test("retries an inconclusive update check after the short cache", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = countingUpdates(async () => ({ status: "unknown" }), {
|
||||
now: () => time,
|
||||
});
|
||||
await installations.checkForUpdates("codex");
|
||||
time = 29_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(1);
|
||||
time = 31_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps update checks and inspection in separate caches", async () => {
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
return { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
});
|
||||
});
|
||||
|
||||
test("asks for updates again once the installed version changes", async () => {
|
||||
let time = 0;
|
||||
let installed = "1.0.0";
|
||||
let checks = 0;
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
// The app sends inspect and check together; the check must not answer first.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return { status: "installed", version: installed };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
checks += 1;
|
||||
return installed === "1.0.0"
|
||||
? { status: "available", versions: ["2.0.0"] }
|
||||
: { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
inspectCacheMs: 30_000,
|
||||
updateCheckCacheMs: 3_600_000,
|
||||
now: () => time,
|
||||
});
|
||||
const together = () =>
|
||||
Promise.all([installations.inspect("codex"), installations.checkForUpdates("codex")]);
|
||||
await expect(together()).resolves.toEqual([
|
||||
{ inspection: { status: "installed", version: "1.0.0" } },
|
||||
{ status: "available", versions: ["2.0.0"] },
|
||||
]);
|
||||
|
||||
// The CLI updated itself outside Paseo.
|
||||
installed = "2.0.0";
|
||||
time = 31_000;
|
||||
await expect(together()).resolves.toEqual([
|
||||
{ inspection: { status: "installed", version: "2.0.0" } },
|
||||
{ status: "current" },
|
||||
]);
|
||||
expect(checks).toBe(2);
|
||||
|
||||
// Two clients reopening together after another change share one new check.
|
||||
installed = "3.0.0";
|
||||
time = 93_000;
|
||||
await Promise.all([together(), together()]);
|
||||
expect(checks).toBe(3);
|
||||
|
||||
// The same version again keeps the long-cached answer.
|
||||
time = 124_000;
|
||||
await together();
|
||||
expect(checks).toBe(3);
|
||||
});
|
||||
|
||||
test("an update check never starts an inspection or waits on one to ask the provider", async () => {
|
||||
let inspections = 0;
|
||||
let finishInspection: () => void = () => {};
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
inspections += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
finishInspection = resolve;
|
||||
});
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
return { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
|
||||
expect(inspections).toBe(0);
|
||||
|
||||
const inspection = installations.inspect("codex");
|
||||
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
|
||||
status: "current",
|
||||
});
|
||||
finishInspection();
|
||||
await inspection;
|
||||
});
|
||||
|
||||
test("aborts a slow update check and reports it as an error", async () => {
|
||||
let aborted = false;
|
||||
function waitForAbort(signal: AbortSignal) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
}
|
||||
const { installations } = countingUpdates(({ signal }) => waitForAbort(signal), {
|
||||
updateCheckTimeoutMs: 10,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Timed out after 10ms",
|
||||
});
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("aborts a slow inspect and reports it as an error", async () => {
|
||||
let aborted = false;
|
||||
function waitForAbort(signal: AbortSignal) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
}
|
||||
const { installations } = counting(({ signal }) => waitForAbort(signal), {
|
||||
inspectTimeoutMs: 10,
|
||||
});
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "error", message: "Timed out after 10ms" },
|
||||
});
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider updates", () => {
|
||||
function updating(
|
||||
update: NonNullable<AgentClientInstallation["update"]>,
|
||||
options: {
|
||||
inspect?: AgentClientInstallation["inspect"];
|
||||
refreshProvider?: (provider: string) => Promise<void>;
|
||||
updateOutputLimit?: number;
|
||||
} = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
updateUrl: "https://example.com/update",
|
||||
inspect: options.inspect,
|
||||
update(context) {
|
||||
calls += 1;
|
||||
return update(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
refreshProvider: options.refreshProvider,
|
||||
updateOutputLimit: options.updateOutputLimit,
|
||||
});
|
||||
const progress: ProviderInstallationUpdateProgress[] = [];
|
||||
installations.observeUpdates("codex", (event) => progress.push(event));
|
||||
return { installations, progress, calls: () => calls };
|
||||
}
|
||||
|
||||
function settled(installations: ProviderInstallations): Promise<ProviderInstallationUpdate> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = installations.observeUpdates("codex", (event) => {
|
||||
if (event.operation.status === "running") return;
|
||||
stop();
|
||||
resolve(installations.getUpdate("codex")!);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("passes output and the success message through unchanged, in order", async () => {
|
||||
const { installations, progress } = updating(async ({ report }) => {
|
||||
report({ type: "message", text: OPAQUE_MESSAGE });
|
||||
report({ type: "stdout", text: " added 1 package\r\n" });
|
||||
report({ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" });
|
||||
report({ type: "stdout", text: "" });
|
||||
return { status: "succeeded", message: OPAQUE_MESSAGE };
|
||||
});
|
||||
const done = settled(installations);
|
||||
const started = installations.update("codex");
|
||||
expect(started.operation.status).toBe("running");
|
||||
|
||||
const update = await done;
|
||||
const output = [
|
||||
{ type: "message", text: OPAQUE_MESSAGE },
|
||||
{ type: "stdout", text: " added 1 package\r\n" },
|
||||
{ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" },
|
||||
{ type: "stdout", text: "" },
|
||||
];
|
||||
expect(update).toEqual({
|
||||
operation: {
|
||||
status: "succeeded",
|
||||
id: started.operation.id,
|
||||
startedAt: expect.any(String),
|
||||
finishedAt: expect.any(String),
|
||||
message: OPAQUE_MESSAGE,
|
||||
},
|
||||
output,
|
||||
});
|
||||
expect(progress.map((event) => event.operation.status)).toEqual([
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"succeeded",
|
||||
]);
|
||||
expect(progress.flatMap((event) => event.output)).toEqual(output);
|
||||
});
|
||||
|
||||
test("reports a failure with the provider's message and keeps its output", async () => {
|
||||
const { installations } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" });
|
||||
report({ type: "stderr", text: "npm error code EACCES\n" });
|
||||
return { status: "failed", message: OPAQUE_MESSAGE };
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toEqual({
|
||||
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
|
||||
output: [
|
||||
{ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" },
|
||||
{ type: "stderr", text: "npm error code EACCES\n" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("turns a throw into a failure with its message and keeps earlier output", async () => {
|
||||
const { installations } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "step 1\n" });
|
||||
throw new Error(OPAQUE_MESSAGE);
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toEqual({
|
||||
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
|
||||
output: [{ type: "stdout", text: "step 1\n" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("a second request while an update runs joins it instead of starting another", async () => {
|
||||
let finish!: () => void;
|
||||
const { installations, calls } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "working\n" });
|
||||
await new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
return { status: "succeeded" };
|
||||
});
|
||||
const first = installations.update("codex");
|
||||
const second = installations.update("codex");
|
||||
expect(second.operation.id).toBe(first.operation.id);
|
||||
expect(second.output).toEqual([{ type: "stdout", text: "working\n" }]);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
const done = settled(installations);
|
||||
finish();
|
||||
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
|
||||
const third = installations.update("codex");
|
||||
expect(third.operation.id).not.toBe(first.operation.id);
|
||||
expect(calls()).toBe(2);
|
||||
});
|
||||
|
||||
test("after success the next read asks the provider again and discovery refreshes", async () => {
|
||||
let version = "1.0.0";
|
||||
const refreshed: string[] = [];
|
||||
const { installations } = updating(
|
||||
async () => {
|
||||
version = "2.0.0";
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{
|
||||
inspect: async () => ({ status: "installed", version }),
|
||||
refreshProvider: async (provider) => {
|
||||
refreshed.push(provider);
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "1.0.0" },
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await done;
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "2.0.0" },
|
||||
});
|
||||
expect(refreshed).toEqual(["codex"]);
|
||||
});
|
||||
|
||||
test("a failed refresh after success still reports success; a failure keeps the caches", async () => {
|
||||
let version = "1.0.0";
|
||||
let result: "succeeded" | "failed" = "failed";
|
||||
const { installations } = updating(
|
||||
async () => {
|
||||
version = "2.0.0";
|
||||
return result === "succeeded" ? { status: result } : { status: result, message: "no" };
|
||||
},
|
||||
{
|
||||
inspect: async () => ({ status: "installed", version }),
|
||||
refreshProvider: async () => {
|
||||
throw new Error("discovery failed");
|
||||
},
|
||||
},
|
||||
);
|
||||
await installations.inspect("codex");
|
||||
let done = settled(installations);
|
||||
installations.update("codex");
|
||||
await done;
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "1.0.0" },
|
||||
});
|
||||
|
||||
result = "succeeded";
|
||||
done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
|
||||
});
|
||||
|
||||
test("retains the most recent output within the limit and marks the drop", async () => {
|
||||
const { installations, progress } = updating(
|
||||
async ({ report }) => {
|
||||
report({ type: "stdout", text: "aaaa" });
|
||||
report({ type: "stdout", text: "bbbb" });
|
||||
report({ type: "stderr", text: "cccc" });
|
||||
report({ type: "stdout", text: "0123456789XYZ" });
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{ updateOutputLimit: 10 },
|
||||
);
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
const update = await done;
|
||||
expect(update.output).toEqual([{ type: "stdout", text: "3456789XYZ" }]);
|
||||
expect(update.truncated).toBe(true);
|
||||
expect(progress.flatMap((event) => event.output).at(-1)).toEqual({
|
||||
type: "stdout",
|
||||
text: "3456789XYZ",
|
||||
});
|
||||
|
||||
const partial = updating(
|
||||
async ({ report }) => {
|
||||
report({ type: "stdout", text: "aaaa" });
|
||||
report({ type: "stdout", text: "bbbb" });
|
||||
report({ type: "stderr", text: "cccc" });
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{ updateOutputLimit: 10 },
|
||||
);
|
||||
const partialDone = settled(partial.installations);
|
||||
partial.installations.update("codex");
|
||||
await expect(partialDone).resolves.toMatchObject({
|
||||
output: [
|
||||
{ type: "stdout", text: "bbbb" },
|
||||
{ type: "stderr", text: "cccc" },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("advertises the update only when the provider declares one", async () => {
|
||||
const { installations } = updating(async () => ({ status: "succeeded" }));
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
updateUrl: "https://example.com/update",
|
||||
canUpdate: true,
|
||||
});
|
||||
|
||||
const plain = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => builtinClient({ updateUrl: "https://example.com/update" }),
|
||||
});
|
||||
await expect(plain.inspect("codex")).resolves.toEqual({
|
||||
updateUrl: "https://example.com/update",
|
||||
});
|
||||
expect(() => plain.update("codex")).toThrow("Provider codex cannot update itself");
|
||||
expect(plain.getUpdate("codex")).toBeNull();
|
||||
});
|
||||
|
||||
test("close aborts a running update, which settles with the provider's answer", async () => {
|
||||
function failOnAbort(signal: AbortSignal): Promise<ProviderUpdateResult> {
|
||||
return new Promise((resolve) => {
|
||||
const stopped = () =>
|
||||
resolve({ status: "failed", message: `stopped: ${String(signal.reason)}` });
|
||||
signal.addEventListener("abort", stopped);
|
||||
});
|
||||
}
|
||||
const { installations } = updating(({ signal }) => failOnAbort(signal));
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
installations.close();
|
||||
await expect(done).resolves.toMatchObject({
|
||||
operation: { status: "failed", message: "stopped: Error: The daemon is stopping" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
ProviderInstallation,
|
||||
ProviderInstallationInspection,
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateCheck,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
ProviderInspectionSchema,
|
||||
ProviderUpdateCheckSchema,
|
||||
ProviderUpdateReportSchema,
|
||||
ProviderUpdateResultSchema,
|
||||
type ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentClientInstallationContext,
|
||||
AgentClientUpdateContext,
|
||||
AgentProvider,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { ProviderUpdateOperation } from "./provider-update-operation.js";
|
||||
|
||||
const DEFAULT_INSPECT_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_INSPECT_CACHE_MS = 30_000;
|
||||
// Below the plugin RPC timeout, so a slow plugin check reports this deadline.
|
||||
const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 25_000;
|
||||
const DEFAULT_UPDATE_CHECK_CACHE_MS = 60 * 60_000;
|
||||
/** Characters of updater output kept per update; older output is dropped first. */
|
||||
export const DEFAULT_UPDATE_OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
export interface ProviderInstallationsOptions {
|
||||
logger: Logger;
|
||||
/** The current client for a configured provider, or null when the provider is unknown. */
|
||||
resolveClient(provider: AgentProvider): AgentClient | null;
|
||||
inspectTimeoutMs?: number;
|
||||
inspectCacheMs?: number;
|
||||
updateCheckTimeoutMs?: number;
|
||||
/** How long a conclusive update check is reused. Failed checks use `inspectCacheMs`. */
|
||||
updateCheckCacheMs?: number;
|
||||
/** Rediscovers the provider after a successful update. A failure is logged, not reported. */
|
||||
refreshProvider?(provider: AgentProvider): Promise<void>;
|
||||
updateOutputLimit?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export type ProviderUpdateListener = (progress: ProviderInstallationUpdateProgress) => void;
|
||||
|
||||
export interface InspectProviderInstallationOptions {
|
||||
/** Skip the settled-result cache. Joins a call that is already running. */
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/** A wire result: the provider's answer, or the `failed` value when the call threw or timed out. */
|
||||
interface ProviderCallResult {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ProviderCall<T extends ProviderCallResult> {
|
||||
name: string;
|
||||
/** Validates the provider's answer; throws when it breaks the contract. */
|
||||
parse(value: unknown): T;
|
||||
timeoutMs: number;
|
||||
/** How long a settled result is reused. */
|
||||
cacheMs(result: T): number;
|
||||
run(context: AgentClientInstallationContext): Promise<unknown>;
|
||||
failed(message: string): T;
|
||||
}
|
||||
|
||||
interface CachedRead<T> {
|
||||
promise: Promise<T>;
|
||||
settledAt: number | null;
|
||||
cacheMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls provider installation callbacks for every provider, built-in or plugin, through
|
||||
* `AgentClient.installation`. Owns deadlines, in-flight sharing and result caching. Results are
|
||||
* keyed by client, so a provider configuration change, which replaces the client, starts fresh.
|
||||
* Inspection and update checks are cached separately so the installed version never waits on an
|
||||
* update check.
|
||||
*/
|
||||
export class ProviderInstallations {
|
||||
private readonly logger: Logger;
|
||||
private readonly resolveClient: ProviderInstallationsOptions["resolveClient"];
|
||||
private readonly inspectTimeoutMs: number;
|
||||
private readonly inspectCacheMs: number;
|
||||
private readonly updateCheckTimeoutMs: number;
|
||||
private readonly updateCheckCacheMs: number;
|
||||
private readonly refreshProvider: ProviderInstallationsOptions["refreshProvider"];
|
||||
private readonly updateOutputLimit: number;
|
||||
private readonly now: () => number;
|
||||
private readonly updates = new Map<AgentProvider, ProviderUpdateOperation>();
|
||||
private readonly updateListeners = new Map<AgentProvider, Set<ProviderUpdateListener>>();
|
||||
private readonly runningUpdates = new Set<AbortController>();
|
||||
private readonly inspections = new WeakMap<
|
||||
AgentClient,
|
||||
CachedRead<ProviderInstallationInspection>
|
||||
>();
|
||||
private readonly updateChecks = new WeakMap<
|
||||
AgentClient,
|
||||
CachedRead<ProviderInstallationUpdateCheck>
|
||||
>();
|
||||
/** The installed version each cached update check answered for, compared as an opaque string. */
|
||||
private readonly checkedVersions = new WeakMap<
|
||||
CachedRead<ProviderInstallationUpdateCheck>,
|
||||
string | null
|
||||
>();
|
||||
|
||||
constructor(options: ProviderInstallationsOptions) {
|
||||
this.logger = options.logger;
|
||||
this.resolveClient = options.resolveClient;
|
||||
this.inspectTimeoutMs = options.inspectTimeoutMs ?? DEFAULT_INSPECT_TIMEOUT_MS;
|
||||
this.inspectCacheMs = options.inspectCacheMs ?? DEFAULT_INSPECT_CACHE_MS;
|
||||
this.updateCheckTimeoutMs = options.updateCheckTimeoutMs ?? DEFAULT_UPDATE_CHECK_TIMEOUT_MS;
|
||||
this.updateCheckCacheMs = options.updateCheckCacheMs ?? DEFAULT_UPDATE_CHECK_CACHE_MS;
|
||||
this.refreshProvider = options.refreshProvider;
|
||||
this.updateOutputLimit = options.updateOutputLimit ?? DEFAULT_UPDATE_OUTPUT_LIMIT;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
/** Links and installed state, or null when the provider declares no installation contract. */
|
||||
async inspect(
|
||||
provider: AgentProvider,
|
||||
options: InspectProviderInstallationOptions = {},
|
||||
): Promise<ProviderInstallation | null> {
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
if (!installation) return null;
|
||||
const result: ProviderInstallation = {};
|
||||
if (installation.installUrl) result.installUrl = installation.installUrl;
|
||||
if (installation.updateUrl) result.updateUrl = installation.updateUrl;
|
||||
if (installation.update) result.canUpdate = true;
|
||||
const inspect = installation.inspect;
|
||||
if (inspect) {
|
||||
result.inspection = await this.read(provider, client, this.inspections, options, {
|
||||
name: "inspect",
|
||||
parse: (value) => ProviderInspectionSchema.parse(value),
|
||||
timeoutMs: this.inspectTimeoutMs,
|
||||
cacheMs: () => this.inspectCacheMs,
|
||||
run: (context) => inspect.call(installation, context),
|
||||
failed: (message) => ({ status: "error" as const, message }),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newer version is available, as the provider reports it, or null when the provider
|
||||
* cannot check. A check that throws or misses its deadline is reported as `error`.
|
||||
*/
|
||||
async checkForUpdates(
|
||||
provider: AgentProvider,
|
||||
options: InspectProviderInstallationOptions = {},
|
||||
): Promise<ProviderInstallationUpdateCheck | null> {
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
const checkForUpdates = installation?.checkForUpdates;
|
||||
if (!installation || !checkForUpdates) return null;
|
||||
// A cached check answers only for the version it was computed against, so a CLI updated
|
||||
// outside Paseo is not offered the update it already has. The version comes from an
|
||||
// inspection that is cached or already running; a check never starts one or waits for one
|
||||
// when it asks the provider itself.
|
||||
const knownVersion = this.knownInstalledVersion(client);
|
||||
const cached = this.updateChecks.get(client);
|
||||
if (cached && knownVersion && !options.refresh && this.isFresh(cached)) {
|
||||
const version = await knownVersion;
|
||||
// Another request may already have replaced the stale entry; share its check.
|
||||
if (
|
||||
version !== undefined &&
|
||||
this.checkedVersions.get(cached) !== version &&
|
||||
this.updateChecks.get(client) === cached
|
||||
)
|
||||
this.updateChecks.delete(client);
|
||||
}
|
||||
const check = this.read(provider, client, this.updateChecks, options, {
|
||||
name: "update check",
|
||||
parse: (value) => ProviderUpdateCheckSchema.parse(value),
|
||||
timeoutMs: this.updateCheckTimeoutMs,
|
||||
// Inconclusive answers are retried soon; a conclusive one holds for the long cache.
|
||||
cacheMs: (result) =>
|
||||
result.status === "current" || result.status === "available"
|
||||
? this.updateCheckCacheMs
|
||||
: this.inspectCacheMs,
|
||||
run: (context) => checkForUpdates.call(installation, context),
|
||||
failed: (message) => ({ status: "error" as const, message }),
|
||||
});
|
||||
const entry = this.updateChecks.get(client);
|
||||
if (entry && knownVersion && !this.checkedVersions.has(entry))
|
||||
void this.recordCheckedVersion(entry, knownVersion);
|
||||
return check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the provider's update, or returns the one already running. The update runs until the
|
||||
* provider settles it, independent of any client; only `close` aborts it. Throws when the
|
||||
* provider declares no `update`.
|
||||
*/
|
||||
update(provider: AgentProvider): ProviderInstallationUpdate {
|
||||
const running = this.updates.get(provider);
|
||||
if (running?.running) return running.snapshot();
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
const update = installation?.update;
|
||||
if (!installation || !update) throw new Error(`Provider ${provider} cannot update itself`);
|
||||
const operation = new ProviderUpdateOperation({
|
||||
id: randomUUID(),
|
||||
provider,
|
||||
startedAt: this.timestamp(),
|
||||
outputLimit: this.updateOutputLimit,
|
||||
publish: (progress) => {
|
||||
for (const listener of this.updateListeners.get(provider) ?? []) listener(progress);
|
||||
},
|
||||
});
|
||||
this.updates.set(provider, operation);
|
||||
void this.runUpdate(provider, client, operation, (context) =>
|
||||
update.call(installation, context),
|
||||
);
|
||||
return operation.snapshot();
|
||||
}
|
||||
|
||||
/** The latest update of the provider since the daemon started, or null. */
|
||||
getUpdate(provider: AgentProvider): ProviderInstallationUpdate | null {
|
||||
return this.updates.get(provider)?.snapshot() ?? null;
|
||||
}
|
||||
|
||||
/** Calls `listener` when an update of the provider starts, reports output or settles. */
|
||||
observeUpdates(provider: AgentProvider, listener: ProviderUpdateListener): () => void {
|
||||
let listeners = this.updateListeners.get(provider);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
this.updateListeners.set(provider, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) this.updateListeners.delete(provider);
|
||||
};
|
||||
}
|
||||
|
||||
/** Aborts running updates. Each settles as failed with the provider's answer to the abort. */
|
||||
close(): void {
|
||||
for (const controller of this.runningUpdates)
|
||||
controller.abort(new Error("The daemon is stopping"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed version from an inspection that is fresh or still running, or undefined when
|
||||
* there is none. Resolves to undefined when the inspection failed, which says nothing about
|
||||
* the version.
|
||||
*/
|
||||
private knownInstalledVersion(
|
||||
client: AgentClient,
|
||||
): Promise<string | null | undefined> | undefined {
|
||||
const read = this.inspections.get(client);
|
||||
if (!read || (read.settledAt !== null && !this.isFresh(read))) return undefined;
|
||||
return read.promise.then((inspection) => {
|
||||
if (inspection.status === "error") return undefined;
|
||||
return inspection.status === "installed" ? (inspection.version ?? null) : null;
|
||||
});
|
||||
}
|
||||
|
||||
private async recordCheckedVersion(
|
||||
entry: CachedRead<ProviderInstallationUpdateCheck>,
|
||||
knownVersion: Promise<string | null | undefined>,
|
||||
): Promise<void> {
|
||||
const version = await knownVersion;
|
||||
if (version !== undefined && !this.checkedVersions.has(entry))
|
||||
this.checkedVersions.set(entry, version);
|
||||
}
|
||||
|
||||
private async runUpdate(
|
||||
provider: AgentProvider,
|
||||
client: AgentClient,
|
||||
operation: ProviderUpdateOperation,
|
||||
run: (context: AgentClientUpdateContext) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
this.runningUpdates.add(controller);
|
||||
let result: ProviderUpdateResult;
|
||||
try {
|
||||
const answer = await run({
|
||||
signal: controller.signal,
|
||||
report: (event) => {
|
||||
const parsed = ProviderUpdateReportSchema.safeParse(event);
|
||||
if (parsed.success) operation.append(parsed.data);
|
||||
else this.logger.warn({ provider }, "Ignored invalid provider update output");
|
||||
},
|
||||
});
|
||||
result = ProviderUpdateResultSchema.parse(answer);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, provider }, "Provider update failed");
|
||||
result = {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.runningUpdates.delete(controller);
|
||||
}
|
||||
if (result.status === "succeeded") {
|
||||
// The installed version and update state are now stale; read them again on next request.
|
||||
this.inspections.delete(client);
|
||||
this.updateChecks.delete(client);
|
||||
}
|
||||
operation.settle(result, this.timestamp());
|
||||
if (result.status === "succeeded" && this.refreshProvider) {
|
||||
await this.refreshProvider(provider).catch((error: unknown) => {
|
||||
this.logger.warn({ err: error, provider }, "Provider refresh after update failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return new Date(this.now()).toISOString();
|
||||
}
|
||||
|
||||
private requireClient(provider: AgentProvider): AgentClient {
|
||||
const client = this.resolveClient(provider);
|
||||
if (!client) throw new Error(`Provider ${provider} is not configured`);
|
||||
return client;
|
||||
}
|
||||
|
||||
private read<T extends ProviderCallResult>(
|
||||
provider: AgentProvider,
|
||||
client: AgentClient,
|
||||
cache: WeakMap<AgentClient, CachedRead<T>>,
|
||||
options: InspectProviderInstallationOptions,
|
||||
call: ProviderCall<T>,
|
||||
): Promise<T> {
|
||||
const existing = cache.get(client);
|
||||
if (existing && (existing.settledAt === null || (!options.refresh && this.isFresh(existing)))) {
|
||||
return existing.promise;
|
||||
}
|
||||
// invoke settles asynchronously, after `read` is initialized.
|
||||
const promise = this.invoke(provider, call).then((result) => {
|
||||
read.settledAt = this.now();
|
||||
read.cacheMs = call.cacheMs(result);
|
||||
return result;
|
||||
});
|
||||
const read: CachedRead<T> = { promise, settledAt: null, cacheMs: 0 };
|
||||
cache.set(client, read);
|
||||
return read.promise;
|
||||
}
|
||||
|
||||
private isFresh(read: CachedRead<unknown>): boolean {
|
||||
return read.settledAt !== null && this.now() - read.settledAt < read.cacheMs;
|
||||
}
|
||||
|
||||
private async invoke<T extends ProviderCallResult>(
|
||||
provider: AgentProvider,
|
||||
call: ProviderCall<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error(`Timed out after ${call.timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, call.timeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([call.run({ signal: controller.signal }), deadline]);
|
||||
return call.parse(result);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, provider }, `Provider installation ${call.name} failed`);
|
||||
return call.failed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateOperation,
|
||||
ProviderInstallationUpdateOutput,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { ProviderUpdateResult } from "@getpaseo/plugin/server/provider";
|
||||
|
||||
interface ProviderUpdateOperationOptions {
|
||||
id: string;
|
||||
provider: string;
|
||||
startedAt: string;
|
||||
/** Characters of output text retained; the oldest output is dropped beyond it. */
|
||||
outputLimit: number;
|
||||
publish(progress: ProviderInstallationUpdateProgress): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider update: its state and the output it reported, bounded to `outputLimit`
|
||||
* characters. Output reported after the update settles is ignored.
|
||||
*/
|
||||
export class ProviderUpdateOperation {
|
||||
private state: ProviderInstallationUpdateOperation;
|
||||
private readonly output: ProviderInstallationUpdateOutput[] = [];
|
||||
private outputLength = 0;
|
||||
private truncated = false;
|
||||
|
||||
constructor(private readonly options: ProviderUpdateOperationOptions) {
|
||||
this.state = { status: "running", id: options.id, startedAt: options.startedAt };
|
||||
this.publish([]);
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
return this.state.status === "running";
|
||||
}
|
||||
|
||||
append(entry: ProviderInstallationUpdateOutput): void {
|
||||
if (!this.running) return;
|
||||
const limit = this.options.outputLimit;
|
||||
const kept =
|
||||
entry.text.length > limit ? { type: entry.type, text: entry.text.slice(-limit) } : entry;
|
||||
if (kept !== entry) this.truncated = true;
|
||||
this.output.push(kept);
|
||||
this.outputLength += kept.text.length;
|
||||
while (this.outputLength > limit) {
|
||||
const dropped = this.output.shift()!;
|
||||
this.outputLength -= dropped.text.length;
|
||||
this.truncated = true;
|
||||
}
|
||||
this.publish([kept]);
|
||||
}
|
||||
|
||||
settle(result: ProviderUpdateResult, finishedAt: string): void {
|
||||
if (!this.running) return;
|
||||
const { id, startedAt } = this.state;
|
||||
this.state =
|
||||
result.status === "succeeded"
|
||||
? {
|
||||
status: "succeeded",
|
||||
id,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
...(result.message === undefined ? {} : { message: result.message }),
|
||||
}
|
||||
: { status: "failed", id, startedAt, finishedAt, message: result.message };
|
||||
this.publish([]);
|
||||
}
|
||||
|
||||
snapshot(): ProviderInstallationUpdate {
|
||||
return {
|
||||
operation: this.state,
|
||||
output: [...this.output],
|
||||
...(this.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private publish(output: ProviderInstallationUpdateOutput[]): void {
|
||||
this.options.publish({ provider: this.options.provider, operation: this.state, output });
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,7 @@ function wrapClientProvider(
|
||||
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
|
||||
isAvailable: (signal, options) => inner.isAvailable(signal, options),
|
||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||
installation: inner.installation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "./agent-configuration-validator.js";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
import { ProviderInstallations } from "./provider-installation/provider-installations.js";
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
|
||||
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
|
||||
@@ -253,6 +254,8 @@ export class ProviderSnapshotManager {
|
||||
private providerClients: Record<AgentProvider, AgentClient>;
|
||||
private readonly ownedClients = new Set<AgentClient>();
|
||||
private readonly pluginProviders: PluginAgentClientRegistry;
|
||||
/** Installation links and versions; separate from availability and discovery. */
|
||||
readonly installations: ProviderInstallations;
|
||||
private pluginProvidersSettled = false;
|
||||
|
||||
constructor(options: ProviderSnapshotManagerOptions) {
|
||||
@@ -281,6 +284,14 @@ export class ProviderSnapshotManager {
|
||||
...this.extraClients,
|
||||
} as Record<AgentProvider, AgentClient>;
|
||||
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
|
||||
this.installations = new ProviderInstallations({
|
||||
logger: options.logger.child({ module: "provider-installations" }),
|
||||
resolveClient: (provider) => {
|
||||
const definition = this.generation.definitions[provider];
|
||||
return definition ? this.ensureClient(provider, definition) : null;
|
||||
},
|
||||
refreshProvider: (provider) => this.refreshSettingsSnapshot({ providers: [provider] }),
|
||||
});
|
||||
}
|
||||
|
||||
getSnapshot(cwd?: string): ProviderSnapshot {
|
||||
@@ -688,6 +699,7 @@ export class ProviderSnapshotManager {
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.destroyed = true;
|
||||
this.installations.close();
|
||||
for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue();
|
||||
// Materialize a client per enabled provider so provider-owned resources
|
||||
// (background processes, sockets, etc.) get a chance to release even when
|
||||
@@ -698,6 +710,7 @@ export class ProviderSnapshotManager {
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.installations.close();
|
||||
for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue();
|
||||
this.catalogs.clear();
|
||||
this.targets.clear();
|
||||
|
||||
@@ -92,6 +92,15 @@ import {
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
|
||||
import {
|
||||
compareWithLatestRelease,
|
||||
fetchHomebrewCaskVersion,
|
||||
fetchReleaseText,
|
||||
homebrewCaskOf,
|
||||
resolveExecutablePath,
|
||||
type LatestReleaseLookup,
|
||||
} from "../release-check.js";
|
||||
import { runUpdateCommand, updatedForNewAgents } from "../update-command.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -130,6 +139,7 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentClientInstallation,
|
||||
type FetchCatalogOptions,
|
||||
type ImportableProviderSession,
|
||||
type ImportProviderSessionContext,
|
||||
@@ -476,6 +486,8 @@ interface ClaudeAgentClientOptions {
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary?: () => Promise<string>;
|
||||
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
|
||||
/** Where update checks learn the newest release; defaults to Claude Code's release channel. */
|
||||
lookupLatestRelease?: LatestReleaseLookup;
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1608,6 +1620,44 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly discoverModels: typeof discoverClaudeModels;
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
readonly installation: AgentClientInstallation = {
|
||||
installUrl: CLAUDE_INSTALL_URL,
|
||||
updateUrl: CLAUDE_UPDATE_URL,
|
||||
inspect: async ({ signal }) => {
|
||||
const probe = await readClaudeCodeVersion(this.runtimeSettings, signal);
|
||||
if (probe.status === "not_installed") return probe;
|
||||
return probe.version
|
||||
? { status: "installed", version: probe.version }
|
||||
: { status: "installed" };
|
||||
},
|
||||
checkForUpdates: async ({ signal }) => {
|
||||
const probe = await readClaudeCodeVersion(this.runtimeSettings, signal);
|
||||
if (probe.status === "not_installed")
|
||||
return { status: "unknown", message: "Claude Code is not installed" };
|
||||
if (!probe.version)
|
||||
return { status: "unknown", message: "The configured command does not report its version" };
|
||||
const latest = await this.lookupLatestRelease({
|
||||
executablePath: probe.executablePath,
|
||||
signal,
|
||||
});
|
||||
return compareWithLatestRelease({ installed: probe.version, latest });
|
||||
},
|
||||
// `claude update` updates native installs itself and package-manager installs through the
|
||||
// package manager.
|
||||
update: async (context) => {
|
||||
const installed = await resolveInstalledClaude(this.runtimeSettings);
|
||||
if (!installed) return { status: "failed", message: "Claude Code is not installed" };
|
||||
return runUpdateCommand(
|
||||
{
|
||||
command: installed.executable,
|
||||
args: [...installed.args, "update"],
|
||||
env: createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
successMessage: updatedForNewAgents("Claude Code"),
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly logger: Logger;
|
||||
@@ -1615,6 +1665,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
|
||||
private readonly lookupLatestRelease: LatestReleaseLookup;
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
private readonly scheduleImportDescriptors = createImportDescriptorScheduler(
|
||||
(candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal),
|
||||
@@ -1636,6 +1687,13 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.resolveVersion =
|
||||
options.resolveVersion ??
|
||||
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
|
||||
this.lookupLatestRelease =
|
||||
options.lookupLatestRelease ??
|
||||
((input) =>
|
||||
lookupLatestClaudeRelease({
|
||||
...input,
|
||||
configDir: claudeConfigDir(this.buildProviderEnv()),
|
||||
}));
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1923,20 +1981,85 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P
|
||||
);
|
||||
}
|
||||
|
||||
const CLAUDE_INSTALL_URL = "https://code.claude.com/docs/en/setup#install-claude-code";
|
||||
const CLAUDE_UPDATE_URL = "https://code.claude.com/docs/en/setup#update-claude-code";
|
||||
|
||||
type ClaudeCodeVersionRead =
|
||||
| { status: "not_installed" }
|
||||
/** `version` is null when the configured command is a wrapper that does not report Claude Code's version. */
|
||||
| { status: "installed"; version: string | null; executablePath: string };
|
||||
|
||||
const CLAUDE_UPDATE_CHANNELS = new Set(["latest", "stable", "rc"]);
|
||||
|
||||
/** The `autoUpdatesChannel` Claude Code updates from, `latest` unless settings pick another. */
|
||||
async function readClaudeUpdateChannel(configDir: string): Promise<string> {
|
||||
try {
|
||||
const settings: unknown = JSON.parse(
|
||||
await promises.readFile(path.join(configDir, "settings.json"), "utf8"),
|
||||
);
|
||||
const channel =
|
||||
settings && typeof settings === "object"
|
||||
? (settings as Record<string, unknown>).autoUpdatesChannel
|
||||
: undefined;
|
||||
return typeof channel === "string" && CLAUDE_UPDATE_CHANNELS.has(channel) ? channel : "latest";
|
||||
} catch {
|
||||
return "latest";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The release Claude Code's own updater offers: the Homebrew cask for cask installs, otherwise
|
||||
* the version published on the configured update channel.
|
||||
*/
|
||||
async function lookupLatestClaudeRelease(input: {
|
||||
executablePath: string;
|
||||
configDir: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const cask = homebrewCaskOf(input.executablePath);
|
||||
if (cask) return fetchHomebrewCaskVersion(cask, input.signal);
|
||||
const channel = await readClaudeUpdateChannel(input.configDir);
|
||||
return fetchReleaseText(
|
||||
`https://downloads.claude.ai/claude-code-releases/${channel}`,
|
||||
input.signal,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveClaudeCodeVersion(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const read = await readClaudeCodeVersion(runtimeSettings, signal);
|
||||
if (read.status === "not_installed") {
|
||||
throw new Error("Claude binary not found while resolving Claude Code version");
|
||||
}
|
||||
if (!read.version) {
|
||||
throw new Error("Unable to parse Claude Code version from --version output");
|
||||
}
|
||||
return read.version;
|
||||
}
|
||||
|
||||
/** The executable and arguments a session would launch, or null when Claude Code is missing. */
|
||||
async function resolveInstalledClaude(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
): Promise<{ executable: string; args: string[] } | null> {
|
||||
const launch = await resolveProviderLaunch({
|
||||
commandConfig: runtimeSettings?.command,
|
||||
defaultBinary: "claude",
|
||||
});
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
throw new Error("Claude binary not found while resolving Claude Code version");
|
||||
}
|
||||
const executable = availability.resolvedPath ?? launch.command;
|
||||
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
|
||||
if (!availability.available) return null;
|
||||
return { executable: availability.resolvedPath ?? launch.command, args: [...launch.args] };
|
||||
}
|
||||
|
||||
async function readClaudeCodeVersion(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ClaudeCodeVersionRead> {
|
||||
const installed = await resolveInstalledClaude(runtimeSettings);
|
||||
if (!installed) return { status: "not_installed" };
|
||||
const { executable } = installed;
|
||||
const { stdout, stderr } = await execCommand(executable, [...installed.args, "--version"], {
|
||||
...createProviderEnvSpec({ runtimeSettings }),
|
||||
timeout: 5_000,
|
||||
signal,
|
||||
@@ -1948,10 +2071,11 @@ export async function resolveClaudeCodeVersion(
|
||||
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
|
||||
? parseClaudeCodeVersion(rawVersionOutput)
|
||||
: null;
|
||||
if (!version) {
|
||||
throw new Error("Unable to parse Claude Code version from --version output");
|
||||
}
|
||||
return version.join(".");
|
||||
return {
|
||||
status: "installed",
|
||||
version: version ? version.join(".") : null,
|
||||
executablePath: await resolveExecutablePath(executable),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveClaudeAuth(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ProviderUpdateCheck } from "@getpaseo/plugin/server/provider";
|
||||
import type { AgentClientInstallation } from "../agent-sdk-types.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderLaunch,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { readCommandVersionOutput } from "./diagnostic-utils.js";
|
||||
import { runUpdateCommand } from "./update-command.js";
|
||||
|
||||
/** The provider's CLI as configured, found on the daemon's PATH. */
|
||||
export interface InstalledCli {
|
||||
/** The resolved executable. */
|
||||
command: string;
|
||||
/** Arguments the configuration puts before any subcommand. */
|
||||
args: string[];
|
||||
env: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface CliInstallationOptions {
|
||||
/** The product name used in messages, such as "Copilot". */
|
||||
name: string;
|
||||
defaultBinary: string;
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined;
|
||||
installUrl: string;
|
||||
updateUrl?: string;
|
||||
/** The version shown for the CLI's `--version` output. Defaults to its first line. */
|
||||
versionLabel?(output: string): string | undefined;
|
||||
/** Asks the source the CLI's own updater asks. Left out when that source is not known. */
|
||||
checkForUpdates?(input: {
|
||||
cli: InstalledCli;
|
||||
version: string | undefined;
|
||||
signal: AbortSignal;
|
||||
}): Promise<ProviderUpdateCheck>;
|
||||
/** The CLI's own update command. Left out when the CLI cannot update itself. */
|
||||
update?: { args: string[]; successMessage: string };
|
||||
}
|
||||
|
||||
function firstLine(output: string): string | undefined {
|
||||
return output.split("\n")[0]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installation contract for a provider whose CLI reports its version with `--version` and,
|
||||
* where it can, updates itself with a subcommand.
|
||||
*/
|
||||
export function createCliInstallation(options: CliInstallationOptions): AgentClientInstallation {
|
||||
const { name, runtimeSettings } = options;
|
||||
const versionLabel = options.versionLabel ?? firstLine;
|
||||
|
||||
async function resolveCli(): Promise<InstalledCli | null> {
|
||||
const launch = await resolveProviderLaunch({
|
||||
commandConfig: runtimeSettings?.command,
|
||||
defaultBinary: options.defaultBinary,
|
||||
});
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) return null;
|
||||
return {
|
||||
command: availability.resolvedPath ?? launch.command,
|
||||
args: [...launch.args],
|
||||
env: runtimeSettings?.env,
|
||||
};
|
||||
}
|
||||
|
||||
async function readVersion(cli: InstalledCli, signal: AbortSignal): Promise<string | undefined> {
|
||||
const output = await readCommandVersionOutput({
|
||||
command: cli.command,
|
||||
args: [...cli.args, "--version"],
|
||||
env: cli.env,
|
||||
signal,
|
||||
});
|
||||
return versionLabel(output);
|
||||
}
|
||||
|
||||
const installation: AgentClientInstallation = {
|
||||
installUrl: options.installUrl,
|
||||
async inspect({ signal }) {
|
||||
const cli = await resolveCli();
|
||||
if (!cli) return { status: "not_installed" };
|
||||
const version = await readVersion(cli, signal);
|
||||
return version ? { status: "installed", version } : { status: "installed" };
|
||||
},
|
||||
};
|
||||
if (options.updateUrl) installation.updateUrl = options.updateUrl;
|
||||
|
||||
const checkForUpdates = options.checkForUpdates;
|
||||
if (checkForUpdates) {
|
||||
installation.checkForUpdates = async ({ signal }) => {
|
||||
const cli = await resolveCli();
|
||||
if (!cli) return { status: "unknown", message: `${name} is not installed` };
|
||||
return checkForUpdates({ cli, version: await readVersion(cli, signal), signal });
|
||||
};
|
||||
}
|
||||
|
||||
const update = options.update;
|
||||
if (update) {
|
||||
installation.update = async (context) => {
|
||||
const cli = await resolveCli();
|
||||
if (!cli) return { status: "failed", message: `${name} is not installed` };
|
||||
return runUpdateCommand(
|
||||
{
|
||||
command: cli.command,
|
||||
args: [...cli.args, ...update.args],
|
||||
env: createProviderEnvSpec({ runtimeSettings }),
|
||||
successMessage: update.successMessage,
|
||||
},
|
||||
context,
|
||||
);
|
||||
};
|
||||
}
|
||||
return installation;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentClientInstallation,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type SteerActiveTurnOptions,
|
||||
@@ -68,6 +69,7 @@ import { normalizeCodexFilePath } from "./codex/tool-call-detail-parser.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderLaunch,
|
||||
type ProviderRuntimeSettings,
|
||||
type ResolvedProviderLaunch,
|
||||
@@ -115,8 +117,18 @@ import {
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
readCommandVersionOutput,
|
||||
resolveBinaryVersion,
|
||||
} from "./diagnostic-utils.js";
|
||||
import {
|
||||
compareWithLatestRelease,
|
||||
fetchHomebrewCaskVersion,
|
||||
fetchReleaseField,
|
||||
homebrewCaskOf,
|
||||
resolveExecutablePath,
|
||||
type LatestReleaseLookup,
|
||||
} from "./release-check.js";
|
||||
import { runUpdateCommand, updatedForNewAgents } from "./update-command.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import {
|
||||
MODE_APPLIES_NEXT_TURN_NOTICE,
|
||||
@@ -320,6 +332,8 @@ interface CodexAppServerProcessPort {
|
||||
interface CodexAppServerAgentDeps {
|
||||
appServerProcess?: CodexAppServerProcessPort;
|
||||
environment?: Record<string, string>;
|
||||
/** Where update checks learn the newest release; defaults to Codex's own release sources. */
|
||||
lookupLatestRelease?: LatestReleaseLookup;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
@@ -7728,9 +7742,69 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_INSTALL_URL = "https://developers.openai.com/codex/cli";
|
||||
|
||||
/** `codex --version` prints `codex-cli <version>`; report the version part. */
|
||||
function readCodexVersionLabel(output: string): string | undefined {
|
||||
const line = output.split("\n")[0]?.trim() ?? "";
|
||||
return line.replace(/^codex-cli\s+/i, "") || undefined;
|
||||
}
|
||||
|
||||
type InstalledCodex =
|
||||
| { status: "not_installed" }
|
||||
| { status: "installed"; version: string | undefined; executablePath: string };
|
||||
|
||||
/**
|
||||
* The release Codex's own updater offers: the Homebrew cask for cask installs, which lags
|
||||
* upstream, otherwise the latest GitHub release, tagged `rust-v<version>`.
|
||||
*/
|
||||
const lookupLatestCodexRelease: LatestReleaseLookup = async ({ executablePath, signal }) => {
|
||||
const cask = homebrewCaskOf(executablePath);
|
||||
if (cask) return fetchHomebrewCaskVersion(cask, signal);
|
||||
const tag = await fetchReleaseField({
|
||||
url: "https://api.github.com/repos/openai/codex/releases/latest",
|
||||
field: "tag_name",
|
||||
signal,
|
||||
});
|
||||
return tag.replace(/^rust-v/, "");
|
||||
};
|
||||
|
||||
export class CodexAppServerAgentClient implements AgentClient {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
readonly installation: AgentClientInstallation = {
|
||||
installUrl: CODEX_INSTALL_URL,
|
||||
updateUrl: CODEX_INSTALL_URL,
|
||||
inspect: async ({ signal }) => {
|
||||
const installed = await this.readInstalledCodex(signal);
|
||||
return installed.status === "installed"
|
||||
? { status: "installed", version: installed.version }
|
||||
: installed;
|
||||
},
|
||||
checkForUpdates: async ({ signal }) => {
|
||||
const installed = await this.readInstalledCodex(signal);
|
||||
if (installed.status === "not_installed")
|
||||
return { status: "unknown", message: "Codex is not installed" };
|
||||
const lookup = this.deps.lookupLatestRelease ?? lookupLatestCodexRelease;
|
||||
const latest = await lookup({ executablePath: installed.executablePath, signal });
|
||||
return compareWithLatestRelease({ installed: installed.version, latest });
|
||||
},
|
||||
// `codex update` picks the updater for the install method: npm, bun, pnpm, Homebrew or the
|
||||
// standalone installer, and exits non-zero with a reason when it cannot tell.
|
||||
update: async (context) => {
|
||||
const command = await this.resolveInstalledCommand();
|
||||
if (!command) return { status: "failed", message: "Codex is not installed" };
|
||||
return runUpdateCommand(
|
||||
{
|
||||
command: command.command,
|
||||
args: [...command.args, "update"],
|
||||
env: createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
successMessage: updatedForNewAgents("Codex"),
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
private goalsEnabledPromise: Promise<boolean> | null = null;
|
||||
private autoReviewEnabledPromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -7740,6 +7814,40 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
private async readInstalledCodex(signal: AbortSignal): Promise<InstalledCodex> {
|
||||
const installed = await this.resolveInstalledCommand();
|
||||
if (!installed) return { status: "not_installed" };
|
||||
const output = await readCommandVersionOutput({
|
||||
command: installed.command,
|
||||
args: [...installed.args, "--version"],
|
||||
env: this.runtimeSettings?.env,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
status: "installed",
|
||||
version: readCodexVersionLabel(output),
|
||||
executablePath: await resolveExecutablePath(installed.executable),
|
||||
};
|
||||
}
|
||||
|
||||
/** The command a session would launch, or null when Codex is not installed. */
|
||||
private async resolveInstalledCommand(): Promise<{
|
||||
command: string;
|
||||
args: string[];
|
||||
executable: string;
|
||||
} | null> {
|
||||
const launch = await resolveCodexLaunch(this.runtimeSettings);
|
||||
const availability = await checkCodexLaunchAvailable(launch);
|
||||
if (!availability.available) return null;
|
||||
const command =
|
||||
launch.source === "override" ? launch.command : (availability.resolvedPath ?? launch.command);
|
||||
return {
|
||||
command,
|
||||
args: [...launch.args],
|
||||
executable: availability.resolvedPath ?? command,
|
||||
};
|
||||
}
|
||||
|
||||
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user