mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-28 09:34:47 +00:00
Compare commits
10 Commits
worktree-f
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a722045ee8 | ||
|
|
f736116967 | ||
|
|
82fc493520 | ||
|
|
2145d97f18 | ||
|
|
f3997d8082 | ||
|
|
02b19bc3d7 | ||
|
|
5cd54ec345 | ||
|
|
c8909908f5 | ||
|
|
9f5713e802 | ||
|
|
816a5f793f |
184
.github/workflows/publish.yml
vendored
184
.github/workflows/publish.yml
vendored
@@ -98,15 +98,129 @@ jobs:
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
path: |
|
||||
packages/opencode/dist/opencode-darwin*
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: azure/artifact-signing-action@v1
|
||||
with:
|
||||
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
files: |
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
exclude-shared-token-cache-credential: true
|
||||
exclude-visual-studio-credential: true
|
||||
exclude-visual-studio-code-credential: true
|
||||
exclude-azure-cli-credential: false
|
||||
exclude-azure-powershell-credential: true
|
||||
exclude-azure-developer-cli-credential: true
|
||||
exclude-interactive-browser-credential: true
|
||||
|
||||
- name: Verify Windows CLI signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Repack Windows CLI archives
|
||||
working-directory: packages/opencode/dist
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||
|
||||
- name: Upload signed Windows CLI release assets
|
||||
if: needs.version.outputs.release != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
packages/opencode/dist/opencode-windows-arm64
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
|
||||
build-tauri:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -152,6 +266,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -190,6 +312,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
@@ -246,11 +369,34 @@ jobs:
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
- name: Verify signed Windows desktop artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
|
||||
)
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
|
||||
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -292,6 +438,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -326,6 +480,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
@@ -358,6 +513,22 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- name: Verify signed Windows Electron artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @()
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
|
||||
|
||||
foreach ($file in $files | Select-Object -Unique) {
|
||||
$sig = Get-AuthenticodeSignature $file
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-electron-${{ matrix.settings.target }}
|
||||
@@ -373,6 +544,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-windows
|
||||
- build-tauri
|
||||
- build-electron
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -411,6 +583,16 @@ jobs:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
|
||||
54
.github/workflows/sign-cli.yml
vendored
54
.github/workflows/sign-cli.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: sign-cli
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- brendan/desktop-signpath
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
sign-cli:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
|
||||
- name: Upload unsigned Windows CLI
|
||||
id: upload_unsigned_windows_cli
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-opencode-windows-cli
|
||||
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit SignPath signing request
|
||||
id: submit_signpath_signing_request
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed-opencode-cli
|
||||
|
||||
- name: Upload signed Windows CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-opencode-windows-cli
|
||||
path: signed-opencode-cli/*.exe
|
||||
if-no-files-found: error
|
||||
@@ -1,7 +1,14 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
|
||||
import type {
|
||||
TuiKeybindSet,
|
||||
TuiPlugin,
|
||||
TuiPluginApi,
|
||||
TuiPluginMeta,
|
||||
TuiPluginModule,
|
||||
TuiSlotPlugin,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const bind = {
|
||||
@@ -813,7 +820,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||
])
|
||||
}
|
||||
|
||||
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await api.theme.install("./smoke-theme.json")
|
||||
@@ -846,7 +853,9 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id: "tui-smoke",
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
github-policies:
|
||||
runners:
|
||||
allowed_groups:
|
||||
- "GitHub Actions"
|
||||
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4XhUHjgqinKxOeT8K5hGAjpFA2vzOp8QpEg0uYCZwvg=",
|
||||
"aarch64-linux": "sha256-X2YTNOpJocIkWkkfS8RnuDW+tvj4riHs7CXM+cS9iv0=",
|
||||
"aarch64-darwin": "sha256-pN0rY+cpdW+6gNWeegVprdmhc2H72OZ9WxKDIs1fvJM=",
|
||||
"x86_64-darwin": "sha256-l8+Yz/6UfSPJrdgfcqy/L2SvxN2i9Apv2R0B61rpEmw="
|
||||
"x86_64-linux": "sha256-aqmdiQeFREbUfRi3YX+ot4+CjykDuJpxYQH54W3hxME=",
|
||||
"aarch64-linux": "sha256-ykJp6rFFwXkfJpMRJheTw+r495Wpmx5nj2LKxgSSVDw=",
|
||||
"aarch64-darwin": "sha256-xHGM1rLld8sqkY+lhvec7fWkPPajIE403viIcpsFnk4=",
|
||||
"x86_64-darwin": "sha256-QkGtT76P9Kf2+Ny0rI4CwMrIFzRIXiZwi8KS2o+jECU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
||||
const initialUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
expect(initialFontFamily).toContain("ui-monospace")
|
||||
|
||||
const next = "Test Mono"
|
||||
|
||||
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: next,
|
||||
mono: next,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
||||
const initialCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("Inter")
|
||||
expect(initialFontFamily).toContain("ui-sans-serif")
|
||||
|
||||
const next = "Test Sans"
|
||||
|
||||
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: next,
|
||||
sans: next,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "Reset Mono",
|
||||
mono: "Reset Mono",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "",
|
||||
mono: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("IBM Plex Mono")
|
||||
expect(fontFamily).toContain("ui-monospace")
|
||||
expect(fontFamily).not.toContain("Reset Mono")
|
||||
})
|
||||
|
||||
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "Reset Sans",
|
||||
sans: "Reset Sans",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "",
|
||||
sans: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("Inter")
|
||||
expect(fontFamily).toContain("ui-sans-serif")
|
||||
expect(fontFamily).not.toContain("Reset Sans")
|
||||
})
|
||||
|
||||
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||
|
||||
await code.click()
|
||||
await code.clear()
|
||||
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
mono,
|
||||
sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
expect(updatedMono).not.toBe(initialMono)
|
||||
expect(updatedSans).toContain(sans)
|
||||
expect(updatedSans).not.toBe(initialSans)
|
||||
expect(updatedSettings?.appearance?.font).toBe(mono)
|
||||
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
|
||||
expect(updatedSettings?.appearance?.mono).toBe(mono)
|
||||
expect(updatedSettings?.appearance?.sans).toBe(sans)
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await page.reload()
|
||||
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
mono,
|
||||
sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
expect(rehydratedMono).not.toBe(initialMono)
|
||||
expect(rehydratedSans).toContain(sans)
|
||||
expect(rehydratedSans).not.toBe(initialSans)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(mono)
|
||||
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
|
||||
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
|
||||
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
|
||||
@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const loadSession = () => import("@/pages/session")
|
||||
const Session = lazy(loadSession)
|
||||
const Loading = () => <div class="size-full" />
|
||||
|
||||
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
||||
void loadSession()
|
||||
}
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
@@ -278,7 +283,11 @@ export function AppInterface(props: {
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerProvider
|
||||
defaultServer={props.defaultServer}
|
||||
disableHealthCheck={props.disableHealthCheck}
|
||||
servers={props.servers}
|
||||
>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
|
||||
@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
while (!abort.signal.aborted) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (aborted(error)) return
|
||||
if (streamErrorLogged) return
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
started = true
|
||||
run = (async () => {
|
||||
while (!abort.signal.aborted && started) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (aborted(error)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
if (abort.signal.aborted || !started) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
if (abort.signal.aborted) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(flush)
|
||||
const stop = () => {
|
||||
started = false
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
if (typeof document === "undefined") return
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
}
|
||||
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility)
|
||||
}
|
||||
stop()
|
||||
abort.abort()
|
||||
flush()
|
||||
})
|
||||
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
return {
|
||||
url: currentServer.http.url,
|
||||
client: sdk,
|
||||
event: emitter,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
const s = server.current
|
||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||
|
||||
@@ -72,10 +72,16 @@ function createGlobalSync() {
|
||||
let projectWritten = false
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
let eventFrame: number | undefined
|
||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||
})
|
||||
|
||||
const cacheProjects = () => {
|
||||
setProjectCache(
|
||||
@@ -348,6 +354,20 @@ function createGlobalSync() {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
eventFrame = requestAnimationFrame(() => {
|
||||
eventFrame = undefined
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
globalSDK.event.start()
|
||||
}, 0)
|
||||
})
|
||||
} else {
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
globalSDK.event.start()
|
||||
}, 0)
|
||||
}
|
||||
void bootstrap()
|
||||
})
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ function waitForPaint() {
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
setTimeout(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
}, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const fast = [
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
|
||||
)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
|
||||
@@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
})
|
||||
|
||||
let sessionFrame: number | undefined
|
||||
let sessionTimer: number | undefined
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
sessionFrame = requestAnimationFrame(() => {
|
||||
sessionFrame = undefined
|
||||
sessionTimer = window.setTimeout(() => {
|
||||
sessionTimer = undefined
|
||||
void Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
}, 0)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
|
||||
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -94,7 +94,11 @@ export namespace ServerConnection {
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
init: (props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
disableHealthCheck?: boolean
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const current_ = current()
|
||||
if (!current_) return
|
||||
|
||||
if (props.disableHealthCheck) {
|
||||
setState("healthy", true)
|
||||
return
|
||||
}
|
||||
setState("healthy", undefined)
|
||||
onCleanup(startHealthPolling(current_))
|
||||
})
|
||||
|
||||
@@ -32,8 +32,8 @@ export interface Settings {
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
uiFont: string
|
||||
mono: string
|
||||
sans: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
@@ -43,20 +43,18 @@ export interface Settings {
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
export const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
|
||||
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
|
||||
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
|
||||
const monoKey = "ibm-plex-mono"
|
||||
const monoBase = monoFallback
|
||||
const sansBase = sansFallback
|
||||
|
||||
function input(font: string | undefined, key?: string) {
|
||||
if (!font || font === key || !font.trim()) return ""
|
||||
return font
|
||||
function input(font: string | undefined) {
|
||||
return font ?? ""
|
||||
}
|
||||
|
||||
function family(font: string) {
|
||||
@@ -64,14 +62,14 @@ function family(font: string) {
|
||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function stack(font: string | undefined, base: string, key?: string) {
|
||||
const value = input(font, key).trim()
|
||||
function stack(font: string | undefined, base: string) {
|
||||
const value = font?.trim() ?? ""
|
||||
if (!value) return base
|
||||
return `${family(value)}, ${base}`
|
||||
}
|
||||
|
||||
export function monoInput(font: string | undefined) {
|
||||
return input(font, monoKey)
|
||||
return input(font)
|
||||
}
|
||||
|
||||
export function sansInput(font: string | undefined) {
|
||||
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return stack(font, monoBase, monoKey)
|
||||
return stack(font, monoBase)
|
||||
}
|
||||
|
||||
export function sansFontFamily(font: string | undefined) {
|
||||
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "",
|
||||
uiFont: "",
|
||||
mono: "",
|
||||
sans: "",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
},
|
||||
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value.trim() ? value : "")
|
||||
setStore("appearance", "mono", value.trim() ? value : "")
|
||||
},
|
||||
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
|
||||
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
|
||||
setUIFont(value: string) {
|
||||
setStore("appearance", "uiFont", value.trim() ? value : "")
|
||||
setStore("appearance", "sans", value.trim() ? value : "")
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
|
||||
@@ -544,6 +544,8 @@ export default function Page() {
|
||||
let reviewFrame: number | undefined
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
@@ -718,7 +720,6 @@ export default function Page() {
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
@@ -730,13 +731,47 @@ export default function Page() {
|
||||
if (params.id !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync.session.sync(id, { force: true })
|
||||
void sync.session.todo(id, todos ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
sdk.directory,
|
||||
id,
|
||||
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk.directory !== dir || params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync.session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -1658,6 +1693,8 @@ export default function Page() {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
import type { Configuration } from "electron-builder"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const signScript = path.join(rootDir, "script", "sign-windows.ps1")
|
||||
|
||||
async function signWindows(configuration: { path: string }) {
|
||||
if (process.platform !== "win32") return
|
||||
if (process.env.GITHUB_ACTIONS !== "true") return
|
||||
|
||||
await execFileAsync(
|
||||
"pwsh",
|
||||
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
|
||||
{ cwd: rootDir },
|
||||
)
|
||||
}
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
@@ -44,6 +64,9 @@ const getBase = (): Configuration => ({
|
||||
},
|
||||
win: {
|
||||
icon: `resources/icons/icon.ico`,
|
||||
signtoolOptions: {
|
||||
sign: signWindows,
|
||||
},
|
||||
target: ["nsis"],
|
||||
},
|
||||
nsis: {
|
||||
|
||||
@@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
console.log(`Updated package.json version to ${Script.version}`)
|
||||
|
||||
const sidecarConfig = getCurrentSidecar()
|
||||
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
|
||||
|
||||
const dir = "resources/opencode-binaries"
|
||||
|
||||
await $`mkdir -p ${dir}`
|
||||
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
|
||||
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
|
||||
|
||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) {
|
||||
await $`mkdir -p ${dir}`
|
||||
const dest = windowsify(`${dir}/opencode-cli`)
|
||||
await $`cp ${source} ${dest}`
|
||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
|
||||
}
|
||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||
|
||||
console.log(`Copied ${source} to ${dest}`)
|
||||
|
||||
@@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
console.log(`Updated package.json version to ${Script.version}`)
|
||||
|
||||
const sidecarConfig = getCurrentSidecar()
|
||||
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
|
||||
|
||||
const dir = "src-tauri/target/opencode-binaries"
|
||||
|
||||
await $`mkdir -p ${dir}`
|
||||
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
|
||||
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
|
||||
|
||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
||||
|
||||
@@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA
|
||||
await $`mkdir -p src-tauri/sidecars`
|
||||
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
|
||||
await $`cp ${source} ${dest}`
|
||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
|
||||
}
|
||||
|
||||
console.log(`Copied ${source} to ${dest}`)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"icons/beta/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"signCommand": {
|
||||
"cmd": "powershell",
|
||||
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "icons/beta/icon.ico"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"entitlements": "./entitlements.plist"
|
||||
},
|
||||
"windows": {
|
||||
"signCommand": {
|
||||
"cmd": "powershell",
|
||||
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "icons/dev/icon.ico",
|
||||
"headerImage": "assets/nsis-header.bmp",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"icons/prod/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"signCommand": {
|
||||
"cmd": "powershell",
|
||||
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "icons/prod/icon.ico"
|
||||
}
|
||||
|
||||
@@ -212,8 +212,82 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Session` — `session/index.ts`
|
||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
||||
|
||||
Other services not yet migrated:
|
||||
|
||||
- [ ] `SessionSummary` — `session/summary.ts`
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `SessionRevert` — `session/revert.ts`
|
||||
- [ ] `Instruction` — `session/instruction.ts`
|
||||
- [ ] `ShareNext` — `share/share-next.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Storage` — `storage/storage.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
## Tool interface → Effect
|
||||
|
||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
||||
|
||||
1. Migrate each tool to return Effects
|
||||
2. Update `Tool.define()` factory to work with Effects
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
||||
|
||||
Individual tools, ordered by value:
|
||||
|
||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||
- [ ] `task.ts` — MEDIUM: task state management
|
||||
- [ ] `glob.ts` — LOW: simple async generator
|
||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||
- [ ] `plan.ts` — LOW: plan file operations
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [ ] `file/index.ts` — 11 calls (the File service itself)
|
||||
- [ ] `config/config.ts` — 7 calls
|
||||
- [ ] `auth/index.ts` — 3 calls
|
||||
- [ ] `skill/index.ts` — 3 calls
|
||||
- [ ] `file/time.ts` — 1 call
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [ ] `format/index.ts` — 1 call
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
|
||||
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
|
||||
Current raw fs users that will convert during tool migration:
|
||||
|
||||
- `tool/read.ts` — fs.createReadStream, readline
|
||||
- `tool/apply_patch.ts` — fs/promises
|
||||
- `tool/bash.ts` — fs/promises
|
||||
- `file/ripgrep.ts` — fs/promises
|
||||
- `storage/storage.ts` — fs/promises
|
||||
- `patch/index.ts` — fs, fs/promises
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
|
||||
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
|
||||
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
|
||||
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
|
||||
|
||||
@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
|
||||
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
||||
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
||||
- Package plugins can be installed from CLI or TUI.
|
||||
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
|
||||
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
|
||||
|
||||
## TUI config
|
||||
|
||||
@@ -27,6 +29,7 @@ Example:
|
||||
- `plugin` entries can be either a string spec or `[spec, options]`.
|
||||
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
|
||||
- Relative path specs are resolved relative to the config file that declared them.
|
||||
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
|
||||
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
|
||||
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
|
||||
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||
@@ -46,7 +49,7 @@ Minimal module shape:
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
api.command.register(() => [
|
||||
@@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
||||
])
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id: "acme.demo",
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
```
|
||||
|
||||
- Loader only reads the module default export object. Named exports are ignored.
|
||||
- TUI shape is `default export { id?, tui }`.
|
||||
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
|
||||
- A single module cannot export both `server` and `tui`.
|
||||
- `tui` signature is `(api, options, meta) => Promise<void>`.
|
||||
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
|
||||
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
||||
- File/path plugins must export a non-empty `id`.
|
||||
- npm plugins may omit `id`; package `name` is used.
|
||||
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
|
||||
@@ -137,6 +144,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||
- A package can target `server`, `tui`, or both.
|
||||
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
||||
- There is no uninstall, list, or update CLI command for external plugins.
|
||||
- Local file plugins are configured directly in `tui.json`.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Tips } from "./tips-view"
|
||||
|
||||
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-context"
|
||||
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-files"
|
||||
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Global } from "@/global"
|
||||
|
||||
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-lsp"
|
||||
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-mcp"
|
||||
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
|
||||
@@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { fileURLToPath } from "url"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:plugin-manager"
|
||||
const key = Keybind.parse("space").at(0)
|
||||
@@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
|
||||
<props.api.ui.DialogPrompt
|
||||
title="Install plugin"
|
||||
placeholder="npm package name"
|
||||
busy={busy()}
|
||||
busyText="Installing plugin..."
|
||||
description={() => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
||||
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
|
||||
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
|
||||
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
|
||||
{global() ? "global" : "local"}
|
||||
</text>
|
||||
<Show when={!busy()}>
|
||||
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
onConfirm={(raw) => {
|
||||
@@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
|
||||
])
|
||||
}
|
||||
|
||||
export default {
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
@@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
getDefaultPlugin,
|
||||
isDeprecatedPlugin,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
readV1Plugin,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
@@ -231,9 +231,7 @@ async function loadExternalPlugin(
|
||||
|
||||
const mod = await import(entry)
|
||||
.then((raw) => {
|
||||
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
|
||||
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
||||
return mod
|
||||
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
|
||||
@@ -566,16 +564,13 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
|
||||
}
|
||||
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
|
||||
const plugin = load.module.tui
|
||||
if (!plugin) return []
|
||||
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
return [
|
||||
{
|
||||
id: load.id,
|
||||
load,
|
||||
meta,
|
||||
plugin,
|
||||
plugin: load.module.tui,
|
||||
options,
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { onMount, type JSX } from "solid-js"
|
||||
import { Show, createEffect, onMount, type JSX } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Spinner } from "../component/spinner"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
busy?: boolean
|
||||
busyText?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
@@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
let textarea: TextareaRenderable
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.busy) {
|
||||
if (evt.name === "escape") return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
@@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
dialog.setSize("medium")
|
||||
setTimeout(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
if (props.busy) return
|
||||
textarea.focus()
|
||||
}, 1)
|
||||
textarea.gotoLineEnd()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!textarea || textarea.isDestroyed) return
|
||||
if (props.busy) {
|
||||
textarea.blur()
|
||||
return
|
||||
}
|
||||
textarea.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
@@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
{props.description}
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
if (props.busy) return
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}}
|
||||
height={3}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.text}
|
||||
textColor={props.busy ? theme.textMuted : theme.text}
|
||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||
/>
|
||||
<Show when={props.busy}>
|
||||
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
@@ -344,8 +343,6 @@ export namespace File {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
@@ -515,54 +512,57 @@ export namespace File {
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
if (isImageByExtension(file)) {
|
||||
const exists = yield* appFs.existsSafe(full)
|
||||
if (exists) {
|
||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (isImageByExtension(file)) {
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !knownText) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(full))) {
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
}
|
||||
|
||||
if (encode) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64" as const,
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text" as const, content: "" }
|
||||
}
|
||||
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
|
||||
|
||||
const exists = yield* appFs.existsSafe(full)
|
||||
if (!exists) return { type: "text" as const, content: "" }
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||
|
||||
if (encode) {
|
||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const content = yield* appFs.readFileString(full).pipe(
|
||||
Effect.map((s) => s.trim()),
|
||||
Effect.catch(() => Effect.succeed("")),
|
||||
)
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
if (Instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
@@ -579,51 +579,60 @@ export namespace File {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text", content, patch, diff: formatPatch(patch) }
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
patch,
|
||||
diff: formatPatch(patch),
|
||||
}
|
||||
}
|
||||
return { type: "text", content }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text" as const, content }
|
||||
return { type: "text", content }
|
||||
})
|
||||
})
|
||||
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (Instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (gitignoreText) ig.add(gitignoreText)
|
||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignoreText) ig.add(ignoreText)
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (Instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(Instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of entries) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(Instance.directory, absolute)
|
||||
const type = entry.type === "directory" ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -667,9 +676,7 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
@@ -15,20 +14,13 @@ export namespace AppFileSystem {
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
@@ -43,10 +35,6 @@ export namespace AppFileSystem {
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
@@ -57,19 +45,6 @@ export namespace AppFileSystem {
|
||||
return info?.type === "File"
|
||||
})
|
||||
|
||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((e): DirEntry => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||
}))
|
||||
},
|
||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||
const text = yield* fs.readFileString(path)
|
||||
return JSON.parse(text)
|
||||
@@ -160,10 +135,8 @@ export namespace AppFileSystem {
|
||||
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
@@ -6,7 +8,6 @@ import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
@@ -36,6 +37,7 @@ export namespace Format {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
@@ -98,38 +100,45 @@ export namespace Format {
|
||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||
}
|
||||
|
||||
async function formatFile(filepath: string) {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
function formatFile(filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||
log.info("running", { command: item.command })
|
||||
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
||||
const code = yield* spawner
|
||||
.spawn(
|
||||
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
||||
cwd: Instance.directory,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((handle) => handle.exitCode),
|
||||
Effect.scoped,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to format file", {
|
||||
error: "spawn failed",
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
return ChildProcessSpawner.ExitCode(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (code !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
log.info("init")
|
||||
@@ -162,14 +171,17 @@ export namespace Format {
|
||||
|
||||
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||
const { formatFile } = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => formatFile(filepath))
|
||||
yield* formatFile(filepath)
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,7 +53,7 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
|
||||
// find the right entry in pendingAuths (which is keyed by oauthState).
|
||||
@@ -60,6 +61,80 @@ export namespace McpOAuthCallback {
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function cleanupStateIndex(oauthState: string) {
|
||||
for (const [name, state] of mcpNameToState) {
|
||||
if (state === oauthState) {
|
||||
mcpNameToState.delete(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
cleanupStateIndex(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
cleanupStateIndex(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -69,88 +144,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
for (const [name, s] of mcpNameToState) {
|
||||
if (s === state) {
|
||||
mcpNameToState.delete(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
// Clean up reverse index
|
||||
for (const [name, s] of mcpNameToState) {
|
||||
if (s === state) {
|
||||
mcpNameToState.delete(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
||||
@@ -196,7 +197,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
getDefaultPlugin,
|
||||
isDeprecatedPlugin,
|
||||
parsePluginSpecifier,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
readV1Plugin,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "./shared"
|
||||
|
||||
export namespace Plugin {
|
||||
@@ -35,6 +38,8 @@ export namespace Plugin {
|
||||
type Loaded = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
source: PluginSource
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -112,7 +117,8 @@ export namespace Plugin {
|
||||
const resolved = await resolvePlugin(spec)
|
||||
if (!resolved) return
|
||||
|
||||
if (pluginSource(spec) === "npm") {
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
@@ -156,14 +162,17 @@ export namespace Plugin {
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
source,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
|
||||
if (plugin?.server) {
|
||||
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
|
||||
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||
if (plugin) {
|
||||
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
|
||||
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
|
||||
|
||||
export type PluginSource = "file" | "npm"
|
||||
export type PluginKind = "server" | "tui"
|
||||
type PluginMode = "strict" | "detect"
|
||||
|
||||
export function pluginSource(spec: string): PluginSource {
|
||||
return spec.startsWith("file://") ? "file" : "npm"
|
||||
@@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
export function readV1Plugin(
|
||||
mod: Record<string, unknown>,
|
||||
spec: string,
|
||||
kind: PluginKind,
|
||||
mode: PluginMode = "strict",
|
||||
) {
|
||||
const value = mod.default
|
||||
if (!isRecord(value)) {
|
||||
if (mode === "detect") return
|
||||
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
|
||||
}
|
||||
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
|
||||
|
||||
const server = "server" in value ? value.server : undefined
|
||||
const tui = "tui" in value ? value.tui : undefined
|
||||
if (server !== undefined && typeof server !== "function") {
|
||||
throw new TypeError(`Plugin ${spec} has invalid server export`)
|
||||
}
|
||||
if (tui !== undefined && typeof tui !== "function") {
|
||||
throw new TypeError(`Plugin ${spec} has invalid tui export`)
|
||||
}
|
||||
if (server !== undefined && tui !== undefined) {
|
||||
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
|
||||
}
|
||||
if (kind === "server" && server === undefined) {
|
||||
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
|
||||
}
|
||||
if (kind === "tui" && tui === undefined) {
|
||||
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
|
||||
if (source === "file") {
|
||||
if (id) return id
|
||||
@@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
|
||||
}
|
||||
return pkg.json.name.trim()
|
||||
}
|
||||
|
||||
export function getDefaultPlugin(mod: Record<string, unknown>) {
|
||||
// A single default object keeps v1 detection explicit and avoids scanning exports.
|
||||
const value = mod.default
|
||||
if (!isRecord(value)) return
|
||||
const server = "server" in value ? value.server : undefined
|
||||
const tui = "tui" in value ? value.tui : undefined
|
||||
if (server !== undefined && typeof server !== "function") return
|
||||
if (tui !== undefined && typeof tui !== "function") return
|
||||
if (server === undefined && tui === undefined) return
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
@@ -30,153 +32,194 @@ export namespace SessionCompaction {
|
||||
}
|
||||
|
||||
const COMPACTION_BUFFER = 20_000
|
||||
|
||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
const config = await Config.get()
|
||||
if (config.compaction?.auto === false) return false
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return false
|
||||
|
||||
const count =
|
||||
input.tokens.total ||
|
||||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
|
||||
|
||||
const reserved =
|
||||
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
const usable = input.model.limit.input
|
||||
? input.model.limit.input - reserved
|
||||
: context - ProviderTransform.maxOutputTokens(input.model)
|
||||
return count >= usable
|
||||
}
|
||||
|
||||
export const PRUNE_MINIMUM = 20_000
|
||||
export const PRUNE_PROTECT = 40_000
|
||||
|
||||
const PRUNE_PROTECTED_TOOLS = ["skill"]
|
||||
|
||||
// goes backwards through parts until there are 40_000 tokens worth of tool
|
||||
// calls. then erases output of previous tool calls. idea is to throw away old
|
||||
// tool calls that are no longer relevant.
|
||||
export async function prune(input: { sessionID: SessionID }) {
|
||||
const config = await Config.get()
|
||||
if (config.compaction?.prune === false) return
|
||||
log.info("pruning")
|
||||
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return undefined
|
||||
throw err
|
||||
})
|
||||
if (!msgs) return
|
||||
let total = 0
|
||||
let pruned = 0
|
||||
const toPrune = []
|
||||
let turns = 0
|
||||
|
||||
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
|
||||
const msg = msgs[msgIndex]
|
||||
if (msg.info.role === "user") turns++
|
||||
if (turns < 2) continue
|
||||
if (msg.info.role === "assistant" && msg.info.summary) break loop
|
||||
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
|
||||
const part = msg.parts[partIndex]
|
||||
if (part.type === "tool")
|
||||
if (part.state.status === "completed") {
|
||||
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
|
||||
|
||||
if (part.state.time.compacted) break loop
|
||||
const estimate = Token.estimate(part.state.output)
|
||||
total += estimate
|
||||
if (total > PRUNE_PROTECT) {
|
||||
pruned += estimate
|
||||
toPrune.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("found", { pruned, total })
|
||||
if (pruned > PRUNE_MINIMUM) {
|
||||
for (const part of toPrune) {
|
||||
if (part.state.status === "completed") {
|
||||
part.state.time.compacted = Date.now()
|
||||
await Session.updatePart(part)
|
||||
}
|
||||
}
|
||||
log.info("pruned", { count: toPrune.length })
|
||||
}
|
||||
export interface Interface {
|
||||
readonly isOverflow: (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) => Effect.Effect<boolean>
|
||||
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
|
||||
readonly process: (input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) => Effect.Effect<"continue" | "stop">
|
||||
readonly create: (input: {
|
||||
sessionID: SessionID
|
||||
agent: string
|
||||
model: { providerID: ProviderID; modelID: ModelID }
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export async function process(input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
let messages = input.messages
|
||||
let replay: MessageV2.WithParts | undefined
|
||||
if (input.overflow) {
|
||||
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const msg = input.messages[i]
|
||||
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
|
||||
replay = msg
|
||||
messages = input.messages.slice(0, i)
|
||||
break
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const session = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.compaction?.auto === false) return false
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return false
|
||||
|
||||
const count =
|
||||
input.tokens.total ||
|
||||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
|
||||
|
||||
const reserved =
|
||||
cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
const usable = input.model.limit.input
|
||||
? input.model.limit.input - reserved
|
||||
: context - ProviderTransform.maxOutputTokens(input.model)
|
||||
return count >= usable
|
||||
})
|
||||
|
||||
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
|
||||
// calls, then erases output of older tool calls to free context space
|
||||
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.compaction?.prune === false) return
|
||||
log.info("pruning")
|
||||
|
||||
const msgs = yield* session
|
||||
.messages({ sessionID: input.sessionID })
|
||||
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
|
||||
if (!msgs) return
|
||||
|
||||
let total = 0
|
||||
let pruned = 0
|
||||
const toPrune: MessageV2.ToolPart[] = []
|
||||
let turns = 0
|
||||
|
||||
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
|
||||
const msg = msgs[msgIndex]
|
||||
if (msg.info.role === "user") turns++
|
||||
if (turns < 2) continue
|
||||
if (msg.info.role === "assistant" && msg.info.summary) break loop
|
||||
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
|
||||
const part = msg.parts[partIndex]
|
||||
if (part.type === "tool")
|
||||
if (part.state.status === "completed") {
|
||||
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
|
||||
if (part.state.time.compacted) break loop
|
||||
const estimate = Token.estimate(part.state.output)
|
||||
total += estimate
|
||||
if (total > PRUNE_PROTECT) {
|
||||
pruned += estimate
|
||||
toPrune.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasContent =
|
||||
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
|
||||
if (!hasContent) {
|
||||
replay = undefined
|
||||
messages = input.messages
|
||||
}
|
||||
}
|
||||
|
||||
const agent = await Agent.get("compaction")
|
||||
const model = agent.model
|
||||
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
||||
const msg = (await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: input.parentID,
|
||||
sessionID: input.sessionID,
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})) as MessageV2.Assistant
|
||||
const processor = SessionProcessor.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
abort: input.abort,
|
||||
})
|
||||
// Allow plugins to inject context or replace compaction prompt
|
||||
const compacting = await Plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
{ sessionID: input.sessionID },
|
||||
{ context: [], prompt: undefined },
|
||||
)
|
||||
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
|
||||
log.info("found", { pruned, total })
|
||||
if (pruned > PRUNE_MINIMUM) {
|
||||
for (const part of toPrune) {
|
||||
if (part.state.status === "completed") {
|
||||
part.state.time.compacted = Date.now()
|
||||
yield* session.updatePart(part)
|
||||
}
|
||||
}
|
||||
log.info("pruned", { count: toPrune.length })
|
||||
}
|
||||
})
|
||||
|
||||
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
|
||||
|
||||
let messages = input.messages
|
||||
let replay: MessageV2.WithParts | undefined
|
||||
if (input.overflow) {
|
||||
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const msg = input.messages[i]
|
||||
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
|
||||
replay = msg
|
||||
messages = input.messages.slice(0, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
const hasContent =
|
||||
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
|
||||
if (!hasContent) {
|
||||
replay = undefined
|
||||
messages = input.messages
|
||||
}
|
||||
}
|
||||
|
||||
const agent = yield* agents.get("compaction")
|
||||
const model = yield* Effect.promise(() =>
|
||||
agent.model
|
||||
? Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||
)
|
||||
const msg = (yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: input.parentID,
|
||||
sessionID: input.sessionID,
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})) as MessageV2.Assistant
|
||||
const processor = SessionProcessor.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
abort: input.abort,
|
||||
})
|
||||
// Allow plugins to inject context or replace compaction prompt.
|
||||
const compacting = yield* plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
{ sessionID: input.sessionID },
|
||||
{ context: [], prompt: undefined },
|
||||
)
|
||||
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
|
||||
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
|
||||
The summary that you construct will be used so that another agent can read it and continue the work.
|
||||
|
||||
@@ -204,133 +247,179 @@ When constructing the summary, try to stick to this template:
|
||||
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
|
||||
---`
|
||||
|
||||
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
|
||||
const msgs = structuredClone(messages)
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const result = await processor.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
abort: input.abort,
|
||||
sessionID: input.sessionID,
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...(await MessageV2.toModelMessages(msgs, model, { stripMedia: true })),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: promptText,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
model,
|
||||
})
|
||||
|
||||
if (result === "compact") {
|
||||
processor.message.error = new MessageV2.ContextOverflowError({
|
||||
message: replay
|
||||
? "Conversation history too large to compact - exceeds model context limit"
|
||||
: "Session too large to compact - context exceeds model limit even after stripping media",
|
||||
}).toObject()
|
||||
processor.message.finish = "error"
|
||||
await Session.updateMessage(processor.message)
|
||||
return "stop"
|
||||
}
|
||||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info as MessageV2.User
|
||||
const replayMsg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: original.agent,
|
||||
model: original.model,
|
||||
format: original.format,
|
||||
tools: original.tools,
|
||||
system: original.system,
|
||||
variant: original.variant,
|
||||
})
|
||||
for (const part of replay.parts) {
|
||||
if (part.type === "compaction") continue
|
||||
const replayPart =
|
||||
part.type === "file" && MessageV2.isMedia(part.mime)
|
||||
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
|
||||
: part
|
||||
await Session.updatePart({
|
||||
...replayPart,
|
||||
id: PartID.ascending(),
|
||||
messageID: replayMsg.id,
|
||||
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
|
||||
const msgs = structuredClone(messages)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
||||
const result = yield* Effect.promise(() =>
|
||||
processor.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
abort: input.abort,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...modelMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
},
|
||||
],
|
||||
model,
|
||||
}),
|
||||
)
|
||||
|
||||
if (result === "compact") {
|
||||
processor.message.error = new MessageV2.ContextOverflowError({
|
||||
message: replay
|
||||
? "Conversation history too large to compact - exceeds model context limit"
|
||||
: "Session too large to compact - context exceeds model limit even after stripping media",
|
||||
}).toObject()
|
||||
processor.message.finish = "error"
|
||||
yield* session.updateMessage(processor.message)
|
||||
return "stop"
|
||||
}
|
||||
} else {
|
||||
const continueMsg = await Session.updateMessage({
|
||||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info as MessageV2.User
|
||||
const replayMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: original.agent,
|
||||
model: original.model,
|
||||
format: original.format,
|
||||
tools: original.tools,
|
||||
system: original.system,
|
||||
variant: original.variant,
|
||||
})
|
||||
for (const part of replay.parts) {
|
||||
if (part.type === "compaction") continue
|
||||
const replayPart =
|
||||
part.type === "file" && MessageV2.isMedia(part.mime)
|
||||
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
|
||||
: part
|
||||
yield* session.updatePart({
|
||||
...replayPart,
|
||||
id: PartID.ascending(),
|
||||
messageID: replayMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!replay) {
|
||||
const continueMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: userMessage.agent,
|
||||
model: userMessage.model,
|
||||
})
|
||||
const text =
|
||||
(input.overflow
|
||||
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
||||
: "") +
|
||||
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: continueMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
return result
|
||||
})
|
||||
|
||||
const create = Effect.fn("SessionCompaction.create")(function* (input: {
|
||||
sessionID: SessionID
|
||||
agent: string
|
||||
model: { providerID: ProviderID; modelID: ModelID }
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
const msg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
model: input.model,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
time: { created: Date.now() },
|
||||
agent: userMessage.agent,
|
||||
model: userMessage.model,
|
||||
})
|
||||
const text =
|
||||
(input.overflow
|
||||
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
||||
: "") +
|
||||
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
||||
await Session.updatePart({
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: continueMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
messageID: msg.id,
|
||||
sessionID: msg.sessionID,
|
||||
type: "compaction",
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (processor.message.error) return "stop"
|
||||
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
return "continue"
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
isOverflow,
|
||||
prune,
|
||||
process: processCompaction,
|
||||
create,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
return runPromise((svc) => svc.isOverflow(input))
|
||||
}
|
||||
|
||||
export async function prune(input: { sessionID: SessionID }) {
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export async function process(input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
return runPromise((svc) => svc.process(input))
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string(),
|
||||
model: z.object({
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
}),
|
||||
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const msg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
model: input.model,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: msg.sessionID,
|
||||
type: "compaction",
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
},
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects npm tui plugin that exports server and tui together", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "mods", "acme-plugin")
|
||||
const marker = path.join(dir, "mixed-called.txt")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "acme-plugin",
|
||||
type: "module",
|
||||
exports: { ".": "./index.js", "./tui": "./tui.js" },
|
||||
}),
|
||||
)
|
||||
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
|
||||
await Bun.write(
|
||||
path.join(mod, "tui.js"),
|
||||
`export default {
|
||||
id: "demo.mixed",
|
||||
server: async () => ({}),
|
||||
tui: async () => {
|
||||
await Bun.write(${JSON.stringify(marker)}, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { mod, marker, spec: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_meta: {
|
||||
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
},
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
install.mockRestore()
|
||||
cwd.mockRestore()
|
||||
get.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
|
||||
file,
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.v1-default",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
||||
" return {}",
|
||||
@@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
|
||||
})
|
||||
|
||||
test("rejects v1 file server plugin without id", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const mark = path.join(dir, "called.txt")
|
||||
await Bun.write(
|
||||
file,
|
||||
[
|
||||
"export default {",
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
||||
" return {}",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||
)
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects v1 plugin that exports server and tui together", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const mark = path.join(dir, "called.txt")
|
||||
await Bun.write(
|
||||
file,
|
||||
[
|
||||
"export default {",
|
||||
' id: "demo.mixed",',
|
||||
" server: async () => {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, "server")`,
|
||||
" return {}",
|
||||
" },",
|
||||
" tui: async () => {},",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
||||
)
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
const errors = await errs(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Token } from "../../src/util/token"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import * as ProviderModule from "../../src/provider/provider"
|
||||
import * as SessionProcessorModule from "../../src/session/processor"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
function createModel(opts: {
|
||||
context: number
|
||||
output: number
|
||||
@@ -40,6 +55,105 @@ function createModel(opts: {
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return msg
|
||||
}
|
||||
|
||||
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: root, root },
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
parentID,
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
}
|
||||
await Session.updateMessage(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
|
||||
return Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID,
|
||||
type: "tool",
|
||||
callID: crypto.randomUUID(),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output,
|
||||
title: "done",
|
||||
metadata: {},
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function fake(
|
||||
input: Parameters<(typeof SessionProcessorModule.SessionProcessor)["create"]>[0],
|
||||
result: "continue" | "compact",
|
||||
): ReturnType<(typeof SessionProcessorModule.SessionProcessor)["create"]> {
|
||||
const msg = input.assistantMessage
|
||||
return {
|
||||
get message() {
|
||||
return msg
|
||||
},
|
||||
partFromToolCall() {
|
||||
return {
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: msg.sessionID,
|
||||
type: "tool",
|
||||
callID: "fake",
|
||||
tool: "fake",
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
}
|
||||
},
|
||||
process: async () => result,
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms = 50) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe("session.compaction.isOverflow", () => {
|
||||
test("returns true when token count exceeds usable context", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -227,6 +341,272 @@ describe("session.compaction.isOverflow", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.create", () => {
|
||||
test("creates a compaction user message and part", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
await SessionCompaction.create({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0].info.role).toBe("user")
|
||||
expect(msgs[0].parts).toHaveLength(1)
|
||||
expect(msgs[0].parts[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.prune", () => {
|
||||
test("compacts old completed tool output", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const a = await user(session.id, "first")
|
||||
const b = await assistant(session.id, a.id, tmp.path)
|
||||
await tool(session.id, b.id, "bash", "x".repeat(200_000))
|
||||
await user(session.id, "second")
|
||||
await user(session.id, "third")
|
||||
|
||||
await SessionCompaction.prune({ sessionID: session.id })
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
expect(part?.state.status).toBe("completed")
|
||||
if (part?.type === "tool" && part.state.status === "completed") {
|
||||
expect(part.state.time.compacted).toBeNumber()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips protected skill tool output", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const a = await user(session.id, "first")
|
||||
const b = await assistant(session.id, a.id, tmp.path)
|
||||
await tool(session.id, b.id, "skill", "x".repeat(200_000))
|
||||
await user(session.id, "second")
|
||||
await user(session.id, "third")
|
||||
|
||||
await SessionCompaction.prune({ sessionID: session.id })
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
expect(part?.type).toBe("tool")
|
||||
if (part?.type === "tool" && part.state.status === "completed") {
|
||||
expect(part.state.time.compacted).toBeUndefined()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.compaction.process", () => {
|
||||
test("publishes compacted event on continue", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const done = defer()
|
||||
let seen = false
|
||||
const unsub = Bus.subscribe(SessionCompaction.Event.Compacted, (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
seen = true
|
||||
done.resolve()
|
||||
})
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
done.promise,
|
||||
wait(500).then(() => {
|
||||
throw new Error("timed out waiting for compacted event")
|
||||
}),
|
||||
])
|
||||
unsub()
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(seen).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks summary message as errored on compact result", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "compact"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const summary = (await Session.messages({ sessionID: session.id })).find(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.summary,
|
||||
)
|
||||
|
||||
expect(result).toBe("stop")
|
||||
expect(summary?.info.role).toBe("assistant")
|
||||
if (summary?.info.role === "assistant") {
|
||||
expect(summary.info.finish).toBe("error")
|
||||
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("adds synthetic continue prompt when auto is enabled", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const last = msgs.at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
expect(last?.parts[0]).toMatchObject({
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
})
|
||||
if (last?.parts[0]?.type === "text") {
|
||||
expect(last.parts[0].text).toContain("Continue if you have next steps")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replays the prior user turn on overflow when earlier context exists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "root")
|
||||
const replay = await user(session.id, "image")
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: replay.id,
|
||||
sessionID: session.id,
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "cat.png",
|
||||
url: "https://example.com/cat.png",
|
||||
})
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
expect(last?.parts.some((part) => part.type === "file")).toBe(false)
|
||||
expect(
|
||||
last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
|
||||
).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to overflow guidance when no replayable turn exists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "earlier")
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const result = await SessionCompaction.process({
|
||||
parentID: msg.id,
|
||||
messages: await Session.messages({ sessionID: session.id }),
|
||||
sessionID: session.id,
|
||||
abort: new AbortController().signal,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
})
|
||||
|
||||
const last = (await Session.messages({ sessionID: session.id })).at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
if (last?.parts[0]?.type === "text") {
|
||||
expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("util.token.estimate", () => {
|
||||
test("estimates tokens from text (4 chars per token)", () => {
|
||||
const text = "x".repeat(4000)
|
||||
|
||||
@@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
|
||||
|
||||
export type PluginModule = {
|
||||
id?: string
|
||||
server?: Plugin
|
||||
server: Plugin
|
||||
tui?: never
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
|
||||
import type { JSX, SolidPlugin } from "@opentui/solid"
|
||||
import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
|
||||
import type { Config as PluginConfig, PluginOptions } from "./index.js"
|
||||
|
||||
export type { CliRenderer, SlotMode } from "@opentui/core"
|
||||
|
||||
@@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
busy?: boolean
|
||||
busyText?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
@@ -414,6 +416,8 @@ export type TuiPluginApi = {
|
||||
|
||||
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
|
||||
|
||||
export type TuiPluginModule = PluginModule & {
|
||||
tui?: TuiPlugin
|
||||
export type TuiPluginModule = {
|
||||
id?: string
|
||||
tui: TuiPlugin
|
||||
server?: never
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
BlexMonoNerdFontMono-Bold.woff2
|
||||
@@ -1 +0,0 @@
|
||||
BlexMonoNerdFontMono-Medium.woff2
|
||||
@@ -1 +0,0 @@
|
||||
BlexMonoNerdFontMono-Regular.woff2
|
||||
Binary file not shown.
@@ -2,24 +2,24 @@
|
||||
import * as mod from "./font"
|
||||
|
||||
const docs = `### Overview
|
||||
Loads OpenCode typography assets and mono nerd fonts.
|
||||
Uses native system font stacks for sans and mono typography.
|
||||
|
||||
Render once at the app root or Storybook preview.
|
||||
Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
|
||||
|
||||
### API
|
||||
- No props.
|
||||
|
||||
### Variants and states
|
||||
- Fonts include sans and multiple mono families.
|
||||
- No variants.
|
||||
|
||||
### Behavior
|
||||
- Injects @font-face rules and preload links into the document head.
|
||||
- Compatibility wrapper only. No font assets are injected or preloaded.
|
||||
|
||||
### Accessibility
|
||||
- Not applicable.
|
||||
|
||||
### Theming/tokens
|
||||
- Provides font families used by theme tokens.
|
||||
- Theme tokens come from CSS variables, not this component.
|
||||
|
||||
`
|
||||
|
||||
|
||||
@@ -1,63 +1 @@
|
||||
import { Link, Style } from "@solidjs/meta"
|
||||
import { Show } from "solid-js"
|
||||
import inter from "../assets/fonts/inter.woff2"
|
||||
import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
|
||||
import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
|
||||
import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
|
||||
|
||||
export const Font = () => {
|
||||
return (
|
||||
<>
|
||||
<Style>{`
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("${inter}") format("woff2-variations");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter Fallback";
|
||||
src: local("Arial");
|
||||
size-adjust: 100%;
|
||||
ascent-override: 97%;
|
||||
descent-override: 25%;
|
||||
line-gap-override: 1%;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("${ibmPlexMonoRegular}") format("woff2");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("${ibmPlexMonoMedium}") format("woff2");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("${ibmPlexMonoBold}") format("woff2");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono Fallback";
|
||||
src: local("Courier New");
|
||||
size-adjust: 100%;
|
||||
ascent-override: 97%;
|
||||
descent-override: 25%;
|
||||
line-gap-override: 1%;
|
||||
}
|
||||
`}</Style>
|
||||
<Show when={typeof location === "undefined" || location.protocol !== "file:"}>
|
||||
<Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoRegular} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const Font = () => null
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
:root {
|
||||
--font-family-sans: "Inter", "Inter Fallback";
|
||||
--font-family-sans--font-feature-settings: "ss03" 1;
|
||||
--font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
|
||||
--font-family-mono--font-feature-settings: "ss01" 1;
|
||||
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-family-sans--font-feature-settings: normal;
|
||||
--font-family-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-family-mono--font-feature-settings: normal;
|
||||
|
||||
--font-size-small: 13px;
|
||||
--font-size-base: 14px;
|
||||
|
||||
70
script/sign-windows.ps1
Normal file
70
script/sign-windows.ps1
Normal file
@@ -0,0 +1,70 @@
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]] $Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $Path -or $Path.Count -eq 0) {
|
||||
throw "At least one path is required"
|
||||
}
|
||||
|
||||
if ($env:GITHUB_ACTIONS -ne "true") {
|
||||
Write-Host "Skipping Windows signing because this is not running on GitHub Actions"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$vars = @{
|
||||
endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
|
||||
account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
|
||||
profile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
|
||||
}
|
||||
|
||||
if ($vars.Values | Where-Object { -not $_ }) {
|
||||
Write-Host "Skipping Windows signing because Azure Artifact Signing is not configured"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$moduleVersion = "0.5.8"
|
||||
$module = Get-Module -ListAvailable -Name TrustedSigning | Where-Object { $_.Version -eq [version] $moduleVersion }
|
||||
|
||||
if (-not $module) {
|
||||
try {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Host "NuGet package provider install skipped: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Install-Module -Name TrustedSigning -RequiredVersion $moduleVersion -Force -Repository PSGallery -Scope CurrentUser
|
||||
}
|
||||
|
||||
Import-Module TrustedSigning -RequiredVersion $moduleVersion -Force
|
||||
|
||||
$files = @($Path | ForEach-Object { Resolve-Path $_ -ErrorAction SilentlyContinue } | Select-Object -ExpandProperty Path -Unique)
|
||||
|
||||
if (-not $files -or $files.Count -eq 0) {
|
||||
throw "No files matched the requested paths"
|
||||
}
|
||||
|
||||
$params = @{
|
||||
Endpoint = $vars.endpoint
|
||||
CodeSigningAccountName = $vars.account
|
||||
CertificateProfileName = $vars.profile
|
||||
Files = ($files -join ",")
|
||||
FileDigest = "SHA256"
|
||||
TimestampDigest = "SHA256"
|
||||
TimestampRfc3161 = "http://timestamp.acs.microsoft.com"
|
||||
ExcludeEnvironmentCredential = $true
|
||||
ExcludeWorkloadIdentityCredential = $true
|
||||
ExcludeManagedIdentityCredential = $true
|
||||
ExcludeSharedTokenCacheCredential = $true
|
||||
ExcludeVisualStudioCredential = $true
|
||||
ExcludeVisualStudioCodeCredential = $true
|
||||
ExcludeAzureCliCredential = $false
|
||||
ExcludeAzurePowerShellCredential = $true
|
||||
ExcludeAzureDeveloperCliCredential = $true
|
||||
ExcludeInteractiveBrowserCredential = $true
|
||||
}
|
||||
|
||||
Invoke-TrustedSigning @params
|
||||
Reference in New Issue
Block a user