Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9284e57e | ||
|
|
0766185ff6 | ||
|
|
effb30d98e | ||
|
|
4da69b5a20 | ||
|
|
3d3337c7b8 | ||
|
|
f0b43dbc68 | ||
|
|
b0eb9aec64 | ||
|
|
8c48455ae5 | ||
|
|
292f695395 | ||
|
|
4ea710c735 | ||
|
|
f5d4cb6917 | ||
|
|
1e53e06424 | ||
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd | ||
|
|
dfd397803f | ||
|
|
267f1592c4 | ||
|
|
668ac7fa88 | ||
|
|
43a476e967 | ||
|
|
adbfab5c25 | ||
|
|
02f1284f7f | ||
|
|
a014ce555a | ||
|
|
db3c13c463 | ||
|
|
7c0bf382ba | ||
|
|
6e9c5a88b4 | ||
|
|
0bf22a323f | ||
|
|
cc997576cf | ||
|
|
05f193df7b | ||
|
|
c9b5bb1b7a | ||
|
|
ba1013cd35 | ||
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 |
47
.github/workflows/release-ui.yml
vendored
Normal file
47
.github/workflows/release-ui.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Release UI
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Install Cloudflare worker deps
|
||||
run: npm ci
|
||||
working-directory: packages/cloudflare
|
||||
|
||||
- name: Build UI
|
||||
run: npm run build --workspace @codenomad/ui
|
||||
|
||||
- name: Publish UI zip + update manifest
|
||||
working-directory: packages/cloudflare
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
|
||||
run: npm run release:ui
|
||||
7
.github/workflows/reusable-release.yml
vendored
7
.github/workflows/reusable-release.yml
vendored
@@ -69,6 +69,13 @@ jobs:
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
release-ui:
|
||||
needs: prepare-release
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,4 +7,9 @@ release/
|
||||
.electron-vite/
|
||||
out/
|
||||
.dir-locals.el
|
||||
.opencode/bashOutputs/
|
||||
.opencode/bashOutputs/
|
||||
|
||||
# Local runtime artifacts
|
||||
.codenomad/
|
||||
.tmp/
|
||||
packages/cloudflare/.wrangler/
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -1632,7 +1632,6 @@
|
||||
"version": "2.10.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -2271,7 +2270,6 @@
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
@@ -3674,7 +3672,6 @@
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
@@ -5352,7 +5349,6 @@
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@@ -7324,7 +7320,6 @@
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
@@ -7389,7 +7384,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7423,7 +7418,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -7433,12 +7428,14 @@
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
@@ -7458,14 +7455,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
1
packages/cloudflare/.gitignore
vendored
Normal file
1
packages/cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
1515
packages/cloudflare/package-lock.json
generated
Normal file
1515
packages/cloudflare/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/cloudflare/package.json
Normal file
14
packages/cloudflare/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
"release:ui": "node ./scripts/release-ui.mjs",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
4
packages/cloudflare/release-config.json
Normal file
4
packages/cloudflare/release-config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.8.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createHash } from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const repoRoot = path.resolve(root, "..", "..")
|
||||
|
||||
const releaseConfigPath = path.join(root, "release-config.json")
|
||||
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
|
||||
|
||||
const distDir = path.join(root, "dist")
|
||||
const manifestPath = path.join(distDir, "version.json")
|
||||
|
||||
const args = new Set(process.argv.slice(2))
|
||||
|
||||
function getArgValue(flag) {
|
||||
const idx = process.argv.indexOf(flag)
|
||||
if (idx === -1) return null
|
||||
return process.argv[idx + 1] ?? null
|
||||
}
|
||||
|
||||
const zipPath = getArgValue("--zip")
|
||||
|
||||
if (!zipPath) {
|
||||
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
|
||||
if (!fs.existsSync(resolvedZipPath)) {
|
||||
console.error(`Zip not found: ${resolvedZipPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
|
||||
|
||||
const bucket = process.env.CODENOMAD_R2_BUCKET
|
||||
|
||||
if (!bucket) {
|
||||
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiVersion = uiPackageJson.version
|
||||
const serverVersion = serverPackageJson.version
|
||||
|
||||
if (!uiVersion || !serverVersion) {
|
||||
console.error("Missing version fields in package.json")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
|
||||
|
||||
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
|
||||
|
||||
const manifest = {
|
||||
minServerVersion: releaseConfig.minServerVersion,
|
||||
latestUIVersion: uiVersion,
|
||||
uiPackageURL,
|
||||
sha256,
|
||||
latestServerVersion: serverVersion,
|
||||
latestServerUrl: releaseConfig.latestServerUrl,
|
||||
}
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||
|
||||
const headersPath = path.join(distDir, "_headers")
|
||||
fs.writeFileSync(
|
||||
headersPath,
|
||||
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
console.log(`Wrote ${manifestPath}`)
|
||||
console.log(`Wrote ${headersPath}`)
|
||||
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import { execFileSync } from "child_process"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const repoRoot = path.resolve(root, "..", "..")
|
||||
|
||||
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
|
||||
|
||||
if (!r2Bucket) {
|
||||
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||
const uiVersion = uiPackageJson.version
|
||||
|
||||
if (!uiVersion) {
|
||||
console.error("Missing packages/ui/package.json version")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
|
||||
if (!fs.existsSync(uiBuildDir)) {
|
||||
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
|
||||
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
|
||||
|
||||
try {
|
||||
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
|
||||
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
|
||||
|
||||
// Upload to R2.
|
||||
const objectKey = `ui/ui-${uiVersion}.zip`
|
||||
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
|
||||
|
||||
execFileSync(
|
||||
"npx",
|
||||
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
|
||||
{ cwd: root, stdio: "inherit" },
|
||||
)
|
||||
|
||||
// Generate version.json into packages/cloudflare/dist
|
||||
console.log("[release-ui] Generating version.json")
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CODENOMAD_R2_BUCKET: r2Bucket,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
console.log("[release-ui] Deploying worker")
|
||||
execFileSync("npx", ["wrangler", "deploy"], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
|
||||
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
console.log("[release-ui] Done")
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
9
packages/cloudflare/src/index.ts
Normal file
9
packages/cloudflare/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Env {
|
||||
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
14
packages/cloudflare/wrangler.toml
Normal file
14
packages/cloudflare/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
name = "codenomad-ui-host"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-01-22"
|
||||
|
||||
# Custom domain for the manifest host.
|
||||
# Note: Custom domains apply to all paths on the hostname.
|
||||
[[routes]]
|
||||
pattern = "ui.codenomad.neuralnomads.ai"
|
||||
custom_domain = true
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "404-page"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.16"
|
||||
"@opencode-ai/plugin": "1.1.30"
|
||||
}
|
||||
}
|
||||
|
||||
774
packages/server/package-lock.json
generated
774
packages/server/package-lock.json
generated
@@ -1,20 +1,30 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
@@ -475,6 +485,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
|
||||
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||
@@ -486,6 +505,15 @@
|
||||
"fast-uri": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
||||
@@ -520,6 +548,77 @@
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/reply-from": {
|
||||
"version": "9.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
|
||||
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/error": "^3.0.0",
|
||||
"end-of-stream": "^1.4.4",
|
||||
"fast-content-type-parse": "^1.1.0",
|
||||
"fast-querystring": "^1.0.0",
|
||||
"fastify-plugin": "^4.0.0",
|
||||
"toad-cache": "^3.7.0",
|
||||
"undici": "^5.19.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/reply-from/node_modules/undici": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/send": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
|
||||
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.1",
|
||||
"escape-html": "~1.0.3",
|
||||
"fast-decode-uri-component": "^1.0.1",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/static": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
|
||||
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/accept-negotiator": "^1.0.0",
|
||||
"@fastify/send": "^2.0.0",
|
||||
"content-disposition": "^0.5.3",
|
||||
"fastify-plugin": "^4.0.0",
|
||||
"fastq": "^1.17.0",
|
||||
"glob": "^10.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -548,12 +647,31 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
@@ -593,6 +711,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -674,6 +802,30 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -700,6 +852,48 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
@@ -709,6 +903,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
@@ -725,6 +931,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
@@ -735,6 +983,27 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -777,6 +1046,12 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-content-type-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||
@@ -891,6 +1166,15 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
||||
@@ -905,6 +1189,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -929,6 +1229,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fuzzysort": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
@@ -942,6 +1248,48 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -951,6 +1299,36 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
||||
@@ -977,6 +1355,12 @@
|
||||
"set-cookie-parser": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -984,6 +1368,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.39.6",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
||||
@@ -1008,6 +1428,52 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
@@ -1139,6 +1605,26 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||
@@ -1181,6 +1667,45 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||
@@ -1199,6 +1724,111 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||
@@ -1217,6 +1847,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
@@ -1296,6 +1935,15 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -1310,6 +1958,128 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
@@ -32,9 +32,11 @@
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderRequest {
|
||||
/**
|
||||
* Path identifier for the currently browsed directory.
|
||||
* Matches the `path` parameter used for `/api/filesystem`.
|
||||
*/
|
||||
parentPath?: string
|
||||
/** Single folder name (no separators). */
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderResponse {
|
||||
/**
|
||||
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
|
||||
* Relative for restricted listings, absolute for unrestricted.
|
||||
*/
|
||||
path: string
|
||||
/** Absolute folder path on the server host. */
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
@@ -167,7 +187,6 @@ export type WorkspaceEventType =
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
| "app.releaseAvailable"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
@@ -180,7 +199,6 @@ export type WorkspaceEventPayload =
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
@@ -198,6 +216,19 @@ export interface LatestReleaseInfo {
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface UiMeta {
|
||||
version?: string
|
||||
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||
}
|
||||
|
||||
export interface SupportMeta {
|
||||
supported: boolean
|
||||
message?: string
|
||||
minServerVersion?: string
|
||||
latestServerVersion?: string
|
||||
latestServerUrl?: string
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
@@ -215,8 +246,9 @@ export interface ServerMeta {
|
||||
workspaceRoot: string
|
||||
/** Reachable addresses for this server, external first. */
|
||||
addresses: NetworkAddress[]
|
||||
/** Optional metadata about the most recent public release. */
|
||||
latestRelease?: LatestReleaseInfo
|
||||
serverVersion?: string
|
||||
ui?: UiMeta
|
||||
support?: SupportMeta
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
@@ -29,7 +29,6 @@ export class EventBus extends EventEmitter {
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
this.on("app.releaseAvailable", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
this.off("app.releaseAvailable", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
|
||||
const name = this.normalizeFolderName(folderName)
|
||||
|
||||
if (this.unrestricted) {
|
||||
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
|
||||
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
|
||||
throw new Error("Cannot create folders at drive root")
|
||||
}
|
||||
this.assertDirectoryExists(resolvedParent)
|
||||
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: absolutePath, absolutePath }
|
||||
}
|
||||
|
||||
const normalizedParent = this.normalizeRelativePath(parentPath)
|
||||
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
|
||||
this.assertDirectoryExists(parentAbsolute)
|
||||
|
||||
const relativePath = this.buildRelativePath(normalizedParent, name)
|
||||
const absolutePath = this.toRestrictedAbsolute(relativePath)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private normalizeFolderName(input: string): string {
|
||||
const name = input.trim()
|
||||
if (!name) {
|
||||
throw new Error("Folder name is required")
|
||||
}
|
||||
|
||||
if (name === "." || name === "..") {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.startsWith("~")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
throw new Error("Folder name must not include path separators")
|
||||
}
|
||||
|
||||
if (name.includes("\u0000")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
private assertDirectoryExists(directory: string) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
throw new Error(`Directory does not exist: ${directory}`)
|
||||
}
|
||||
const stats = fs.statSync(directory)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directory}`)
|
||||
}
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
@@ -17,7 +17,7 @@ import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -37,6 +37,9 @@ interface CliOptions {
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
uiAutoUpdate: boolean
|
||||
uiNoUpdate: boolean
|
||||
uiManifestUrl?: string
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
@@ -66,6 +69,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
|
||||
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
|
||||
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
.addOption(
|
||||
new Option("--username <username>", "Username for server authentication")
|
||||
@@ -91,6 +97,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
uiNoUpdate?: boolean
|
||||
uiAutoUpdate?: string
|
||||
uiManifestUrl?: string
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
@@ -101,6 +110,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
@@ -111,6 +123,9 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
uiAutoUpdate,
|
||||
uiNoUpdate: Boolean(parsed.uiNoUpdate),
|
||||
uiManifestUrl: parsed.uiManifestUrl,
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
@@ -127,10 +142,22 @@ function parsePort(input: string): number {
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
if (input && input.trim() === "0.0.0.0") {
|
||||
const trimmed = input?.trim()
|
||||
if (!trimmed) return DEFAULT_HOST
|
||||
|
||||
if (trimmed === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return DEFAULT_HOST
|
||||
|
||||
if (trimmed === "localhost") {
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -149,11 +176,13 @@ async function main() {
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
port: options.port,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
@@ -195,19 +224,36 @@ async function main() {
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const releaseMonitor = startReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
logger: logger.child({ component: "release-monitor" }),
|
||||
onUpdate: (release) => {
|
||||
if (release) {
|
||||
serverMeta.latestRelease = release
|
||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
||||
} else {
|
||||
delete serverMeta.latestRelease
|
||||
}
|
||||
},
|
||||
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
|
||||
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
|
||||
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
|
||||
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
|
||||
|
||||
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
|
||||
|
||||
const uiResolution = await resolveUi({
|
||||
serverVersion: packageJson.version,
|
||||
bundledUiDir: DEFAULT_UI_STATIC_DIR,
|
||||
autoUpdate: autoUpdateEnabled,
|
||||
overrideUiDir: uiDirOverride,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
manifestUrl: options.uiManifestUrl,
|
||||
logger: logger.child({ component: "ui" }),
|
||||
})
|
||||
|
||||
serverMeta.serverVersion = packageJson.version
|
||||
serverMeta.ui = {
|
||||
version: uiResolution.uiVersion,
|
||||
source: uiResolution.source,
|
||||
}
|
||||
serverMeta.support = {
|
||||
supported: uiResolution.supported,
|
||||
message: uiResolution.message,
|
||||
latestServerVersion: uiResolution.latestServerVersion,
|
||||
latestServerUrl: uiResolution.latestServerUrl,
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
@@ -219,8 +265,8 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
|
||||
@@ -256,7 +302,7 @@ async function main() {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
|
||||
releaseMonitor.stop()
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
|
||||
@@ -93,6 +93,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
})
|
||||
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
@@ -113,10 +114,17 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (allowedDevOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
if (allowedDevOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
cb(null, false)
|
||||
},
|
||||
@@ -275,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
||||
includeFiles: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
const FilesystemCreateFolderSchema = z.object({
|
||||
parentPath: z.string().optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/filesystem/folders", async (request, reply) => {
|
||||
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
|
||||
|
||||
try {
|
||||
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
|
||||
reply.code(201)
|
||||
return created
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "EEXIST") {
|
||||
reply.code(409).type("text/plain").send("Folder already exists")
|
||||
return
|
||||
}
|
||||
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
||||
reply.code(403).type("text/plain").send("Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(400).type("text/plain").send((error as Error).message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ function resolvePort(meta: ServerMeta): number {
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
|
||||
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import type { Logger } from "../../logger"
|
||||
import { resolveUi } from "../remote-ui"
|
||||
|
||||
const noopLogger: Logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
trace: () => {},
|
||||
child: () => noopLogger,
|
||||
isLevelEnabled: () => false,
|
||||
} as any
|
||||
|
||||
let tempRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("resolveUi local version preference", () => {
|
||||
it("prefers bundled when bundled version is higher", async () => {
|
||||
const bundledDir = path.join(tempRoot, "bundled")
|
||||
const configDir = path.join(tempRoot, "config")
|
||||
const currentDir = path.join(configDir, "ui", "current")
|
||||
|
||||
await mkdir(bundledDir, { recursive: true })
|
||||
await mkdir(currentDir, { recursive: true })
|
||||
|
||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" }))
|
||||
|
||||
const result = await resolveUi({
|
||||
serverVersion: "0.8.1",
|
||||
bundledUiDir: bundledDir,
|
||||
autoUpdate: false,
|
||||
configDir,
|
||||
logger: noopLogger,
|
||||
})
|
||||
|
||||
assert.equal(result.source, "bundled")
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
})
|
||||
571
packages/server/src/ui/remote-ui.ts
Normal file
571
packages/server/src/ui/remote-ui.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { createHash } from "crypto"
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Readable } from "stream"
|
||||
import { fetch } from "undici"
|
||||
import yauzl from "yauzl"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
export interface RemoteUiManifest {
|
||||
minServerVersion: string
|
||||
latestUIVersion: string
|
||||
uiPackageURL: string
|
||||
sha256: string
|
||||
latestServerVersion?: string
|
||||
latestServerUrl?: string
|
||||
}
|
||||
|
||||
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||
|
||||
export interface UiResolution {
|
||||
uiStaticDir?: string
|
||||
uiDevServerUrl?: string
|
||||
source: UiSource
|
||||
uiVersion?: string
|
||||
supported: boolean
|
||||
message?: string
|
||||
latestServerVersion?: string
|
||||
latestServerUrl?: string
|
||||
minServerVersion?: string
|
||||
}
|
||||
|
||||
export interface RemoteUiOptions {
|
||||
serverVersion: string
|
||||
bundledUiDir: string
|
||||
autoUpdate: boolean
|
||||
overrideUiDir?: string
|
||||
uiDevServerUrl?: string
|
||||
manifestUrl?: string
|
||||
configDir?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
|
||||
|
||||
const MANIFEST_TIMEOUT_MS = 5_000
|
||||
const ZIP_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
|
||||
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
|
||||
|
||||
if (options.uiDevServerUrl) {
|
||||
return {
|
||||
uiDevServerUrl: options.uiDevServerUrl,
|
||||
source: "dev-proxy",
|
||||
supported: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (options.overrideUiDir) {
|
||||
const resolved = await resolveStaticUiDir(options.overrideUiDir)
|
||||
return {
|
||||
uiStaticDir: resolved ?? options.overrideUiDir,
|
||||
source: "override",
|
||||
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
|
||||
supported: true,
|
||||
}
|
||||
}
|
||||
|
||||
const uiRoot = resolveUiCacheRoot(options.configDir)
|
||||
const currentDir = path.join(uiRoot, "current")
|
||||
const previousDir = path.join(uiRoot, "previous")
|
||||
|
||||
if (!options.autoUpdate) {
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
})
|
||||
}
|
||||
|
||||
let manifest: RemoteUiManifest | null = null
|
||||
try {
|
||||
manifest = await fetchManifest(manifestUrl, options.logger)
|
||||
} catch (error) {
|
||||
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
})
|
||||
}
|
||||
|
||||
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
|
||||
if (!supported) {
|
||||
const message = "Upgrade App to use latest features"
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: false,
|
||||
message,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
const bestLocal = await pickBestLocalUi({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
})
|
||||
|
||||
const remoteIsNewer =
|
||||
!bestLocal ||
|
||||
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||
|
||||
if (!remoteIsNewer) {
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await installRemoteUi({
|
||||
manifest,
|
||||
uiRoot,
|
||||
currentDir,
|
||||
previousDir,
|
||||
logger: options.logger,
|
||||
})
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
const installed = await resolveStaticUiDir(currentDir)
|
||||
if (installed) {
|
||||
return {
|
||||
uiStaticDir: installed,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(installed),
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveUiCacheRoot(configDir?: string): string {
|
||||
if (configDir) {
|
||||
return path.join(configDir, "ui")
|
||||
}
|
||||
return path.join(os.homedir(), ".config", "codenomad", "ui")
|
||||
}
|
||||
|
||||
async function resolveFromCacheOrBundled(args: {
|
||||
logger: Logger
|
||||
bundledUiDir: string
|
||||
currentDir: string
|
||||
previousDir: string
|
||||
supported: boolean
|
||||
message?: string
|
||||
latestServerVersion?: string
|
||||
latestServerUrl?: string
|
||||
minServerVersion?: string
|
||||
}): Promise<UiResolution> {
|
||||
const bestLocal = await pickBestLocalUi({
|
||||
logger: args.logger,
|
||||
bundledUiDir: args.bundledUiDir,
|
||||
currentDir: args.currentDir,
|
||||
previousDir: args.previousDir,
|
||||
})
|
||||
|
||||
if (bestLocal) {
|
||||
return {
|
||||
uiStaticDir: bestLocal.uiStaticDir,
|
||||
source: bestLocal.source,
|
||||
uiVersion: bestLocal.uiVersion,
|
||||
supported: args.supported,
|
||||
message: args.message,
|
||||
latestServerVersion: args.latestServerVersion,
|
||||
latestServerUrl: args.latestServerUrl,
|
||||
minServerVersion: args.minServerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
|
||||
return {
|
||||
uiStaticDir: args.bundledUiDir,
|
||||
source: "missing",
|
||||
supported: args.supported,
|
||||
message: args.message,
|
||||
latestServerVersion: args.latestServerVersion,
|
||||
latestServerUrl: args.latestServerUrl,
|
||||
minServerVersion: args.minServerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
async function pickBestLocalUi(args: {
|
||||
logger: Logger
|
||||
bundledUiDir: string
|
||||
currentDir: string
|
||||
previousDir: string
|
||||
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
|
||||
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
|
||||
|
||||
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||
if (currentResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||
if (bundledResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||
if (previousResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: previousResolved,
|
||||
source: "previous",
|
||||
uiVersion: await readUiVersion(previousResolved),
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
|
||||
if (versionCmp !== 0) return -versionCmp
|
||||
return b.priority - a.priority
|
||||
})
|
||||
|
||||
const best = candidates[0]
|
||||
if (!best) return null
|
||||
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
|
||||
}
|
||||
|
||||
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
|
||||
if (!a && !b) return 0
|
||||
if (!a) return -1
|
||||
if (!b) return 1
|
||||
return compareSemverCore(a, b)
|
||||
}
|
||||
|
||||
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||
try {
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
await fsp.access(indexPath, fs.constants.R_OK)
|
||||
return uiDir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface UiVersionFile {
|
||||
uiVersion?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
async function readUiVersion(uiDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
|
||||
const parsed = JSON.parse(content) as UiVersionFile
|
||||
return parsed.uiVersion ?? parsed.version
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Manifest responded with ${response.status}`)
|
||||
}
|
||||
const json = (await response.json()) as RemoteUiManifest
|
||||
validateManifest(json)
|
||||
return json
|
||||
} catch (error) {
|
||||
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifest(manifest: RemoteUiManifest) {
|
||||
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
|
||||
for (const key of required) {
|
||||
const value = manifest[key]
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Manifest missing ${key}`)
|
||||
}
|
||||
}
|
||||
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
|
||||
throw new Error("uiPackageURL must be https")
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
|
||||
throw new Error("sha256 must be 64 hex chars")
|
||||
}
|
||||
}
|
||||
|
||||
async function installRemoteUi(args: {
|
||||
manifest: RemoteUiManifest
|
||||
uiRoot: string
|
||||
currentDir: string
|
||||
previousDir: string
|
||||
logger: Logger
|
||||
}) {
|
||||
await fsp.mkdir(args.uiRoot, { recursive: true })
|
||||
|
||||
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
|
||||
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
|
||||
|
||||
try {
|
||||
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
|
||||
const digest = await sha256File(zipPath)
|
||||
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
|
||||
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
|
||||
}
|
||||
|
||||
await extractZip(zipPath, tmpDir)
|
||||
|
||||
const indexPath = path.join(tmpDir, "index.html")
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
throw new Error("Extracted UI missing index.html")
|
||||
}
|
||||
|
||||
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
|
||||
|
||||
fs.rmSync(args.currentDir, { recursive: true, force: true })
|
||||
fs.renameSync(tmpDir, args.currentDir)
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
fs.rmSync(zipPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
|
||||
try {
|
||||
if (fs.existsSync(args.previousDir)) {
|
||||
fs.rmSync(args.previousDir, { recursive: true, force: true })
|
||||
}
|
||||
if (fs.existsSync(args.currentDir)) {
|
||||
fs.renameSync(args.currentDir, args.previousDir)
|
||||
}
|
||||
} catch (error) {
|
||||
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, targetPath: string, logger: Logger) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/octet-stream",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`UI zip download failed with ${response.status}`)
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
const fileStream = fs.createWriteStream(targetPath)
|
||||
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error("UI zip response missing body")
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(body as any)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
nodeStream.pipe(fileStream)
|
||||
nodeStream.on("error", reject)
|
||||
fileStream.on("error", reject)
|
||||
fileStream.on("finish", () => resolve())
|
||||
})
|
||||
|
||||
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
const hash = createHash("sha256")
|
||||
const stream = fs.createReadStream(filePath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("data", (chunk) => hash.update(chunk))
|
||||
stream.on("error", reject)
|
||||
stream.on("end", () => resolve())
|
||||
})
|
||||
return hash.digest("hex")
|
||||
}
|
||||
|
||||
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
|
||||
await fsp.mkdir(targetDir, { recursive: true })
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
|
||||
if (openErr || !zipfile) {
|
||||
reject(openErr ?? new Error("Unable to open zip"))
|
||||
return
|
||||
}
|
||||
|
||||
const root = path.resolve(targetDir)
|
||||
|
||||
const closeWithError = (error: unknown) => {
|
||||
try {
|
||||
zipfile.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
zipfile.readEntry()
|
||||
|
||||
zipfile.on("entry", (entry) => {
|
||||
// Normalize and guard against zip-slip.
|
||||
const entryPath = entry.fileName.replace(/\\/g, "/")
|
||||
|
||||
const segments = entryPath.split("/").filter(Boolean)
|
||||
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
|
||||
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
|
||||
return
|
||||
}
|
||||
|
||||
const destination = path.resolve(targetDir, entryPath)
|
||||
if (!destination.startsWith(root + path.sep) && destination !== root) {
|
||||
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
|
||||
return
|
||||
}
|
||||
|
||||
const isDirectory = entry.fileName.endsWith("/")
|
||||
|
||||
if (isDirectory) {
|
||||
fsp
|
||||
.mkdir(destination, { recursive: true })
|
||||
.then(() => zipfile.readEntry())
|
||||
.catch((error) => closeWithError(error))
|
||||
return
|
||||
}
|
||||
|
||||
fsp
|
||||
.mkdir(path.dirname(destination), { recursive: true })
|
||||
.then(() => {
|
||||
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
||||
if (streamErr || !readStream) {
|
||||
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
|
||||
return
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(destination)
|
||||
const cleanup = (error?: unknown) => {
|
||||
readStream.destroy()
|
||||
writeStream.destroy()
|
||||
if (error) {
|
||||
closeWithError(error)
|
||||
}
|
||||
}
|
||||
|
||||
readStream.on("error", cleanup)
|
||||
writeStream.on("error", cleanup)
|
||||
writeStream.on("finish", () => zipfile.readEntry())
|
||||
|
||||
readStream.pipe(writeStream)
|
||||
})
|
||||
})
|
||||
.catch((error) => closeWithError(error))
|
||||
})
|
||||
|
||||
zipfile.on("end", () => {
|
||||
zipfile.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
zipfile.on("error", (error) => closeWithError(error))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function compareSemverCore(a: string, b: string): number {
|
||||
const pa = parseSemverCore(a)
|
||||
const pb = parseSemverCore(b)
|
||||
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
|
||||
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
|
||||
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
|
||||
return 0
|
||||
}
|
||||
|
||||
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
|
||||
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
|
||||
const parts = core.split(".")
|
||||
const parsePart = (input: string | undefined) => {
|
||||
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
return {
|
||||
major: parsePart(parts[0]),
|
||||
minor: parsePart(parts[1]),
|
||||
patch: parsePart(parts[2]),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { ChildProcess, spawn, spawnSync } from "child_process"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
@@ -122,10 +122,12 @@ export class WorkspaceRuntime {
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
@@ -259,10 +261,96 @@ export class WorkspaceRuntime {
|
||||
const child = managed.child
|
||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
// Fallback to direct PID kill.
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let escalationTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
child.removeListener("exit", onExit)
|
||||
child.removeListener("error", onError)
|
||||
if (escalationTimer) {
|
||||
clearTimeout(escalationTimer)
|
||||
escalationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
@@ -274,32 +362,30 @@ export class WorkspaceRuntime {
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const resolveIfAlreadyExited = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
if (isAlreadyExited()) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
child.once("exit", onExit)
|
||||
child.once("error", onError)
|
||||
|
||||
if (resolveIfAlreadyExited()) {
|
||||
return
|
||||
}
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, detached: process.platform !== "win32" },
|
||||
"Sending SIGTERM to workspace process (tree/group)",
|
||||
)
|
||||
sendStopSignal("SIGTERM")
|
||||
|
||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
||||
child.kill("SIGKILL")
|
||||
} else {
|
||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
||||
escalationTimer = setTimeout(() => {
|
||||
escalationTimer = null
|
||||
if (isAlreadyExited()) {
|
||||
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
|
||||
return
|
||||
}
|
||||
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.2",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
||||
|
||||
const AlertDialog: Component = () => {
|
||||
let primaryButtonRef: HTMLButtonElement | undefined
|
||||
let promptInputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (alertDialogState()) {
|
||||
queueMicrotask(() => {
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
}
|
||||
const state = alertDialogState()
|
||||
if (!state) return
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (state.type === "prompt") {
|
||||
promptInputRef?.focus()
|
||||
promptInputRef?.select()
|
||||
return
|
||||
}
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{payload.inputLabel || "Arguments"}
|
||||
</label>
|
||||
<input
|
||||
class="modal-search-input mt-2"
|
||||
value={inputValue()}
|
||||
placeholder={payload.inputPlaceholder || ""}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
dismiss(true, payload, inputValue())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
|
||||
<input
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
class="form-input mt-2"
|
||||
value={inputValue()}
|
||||
placeholder={payload.inputPlaceholder || ""}
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
dismiss(true, payload, inputValue())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
{(isConfirm || isPrompt) && (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||
|
||||
function normalizePathKey(input?: string | null) {
|
||||
if (!input || input === "." || input === "./") {
|
||||
@@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
props.onSelect(absolutePath)
|
||||
}
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
|
||||
const name =
|
||||
(await showPromptDialog("Create a new folder in the current directory.", {
|
||||
title: "New Folder",
|
||||
inputLabel: "Folder name",
|
||||
inputPlaceholder: "e.g. my-new-project",
|
||||
confirmLabel: "Create",
|
||||
cancelLabel: "Cancel",
|
||||
}))?.trim() ?? ""
|
||||
if (!name) return
|
||||
|
||||
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
||||
showAlertDialog("Please enter a single folder name.", {
|
||||
variant: "warning",
|
||||
detail: "Folder names cannot include slashes, '..', or '~'.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCreatingFolder(true)
|
||||
try {
|
||||
const parentKey = normalizePathKey(metadata.currentPath)
|
||||
metadataCache.delete(parentKey)
|
||||
inFlightRequests.delete(parentKey)
|
||||
setDirectoryChildren((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(parentKey)
|
||||
return next
|
||||
})
|
||||
|
||||
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
||||
await navigateTo(created.path)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to create folder"
|
||||
showAlertDialog(message, { variant: "error", title: "Unable to create folder" })
|
||||
} finally {
|
||||
setCreatingFolder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function isPathLoading(path: string) {
|
||||
return loadingPaths().has(normalizePathKey(path))
|
||||
}
|
||||
@@ -290,19 +338,32 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<span class="directory-browser-current-label">Current folder</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => void handleCreateFolder()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
{creatingFolder() ? "Creating…" : "New Folder"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
||||
@@ -5,6 +5,7 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -56,6 +57,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
let activeElement: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
activeElement = document.activeElement as HTMLElement | null
|
||||
}
|
||||
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||
const isEditingField =
|
||||
activeElement &&
|
||||
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
|
||||
|
||||
if (isEditingField) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedKey = e.key.toLowerCase()
|
||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||
const blockedKeys = [
|
||||
@@ -248,6 +262,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
getPermissionQueue,
|
||||
getQuestionQueue,
|
||||
getQuestionEnqueuedAtForInstance,
|
||||
setActivePermissionIdForInstance,
|
||||
setActiveQuestionIdForInstance,
|
||||
sendPermissionResponse,
|
||||
} from "../stores/instances"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
@@ -132,6 +131,45 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
|
||||
|
||||
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
||||
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
||||
|
||||
const setPermissionBusy = (permissionId: string, busy: boolean) => {
|
||||
setPermissionSubmitting((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (busy) next.add(permissionId)
|
||||
else next.delete(permissionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setPermissionItemError = (permissionId: string, message: string | null) => {
|
||||
setPermissionError((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!message) next.delete(permissionId)
|
||||
else next.set(permissionId, message)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
|
||||
const permissionId = permission?.id
|
||||
if (!permissionId) return
|
||||
|
||||
if (permissionSubmitting().has(permissionId)) return
|
||||
|
||||
setPermissionBusy(permissionId, true)
|
||||
setPermissionItemError(permissionId, null)
|
||||
|
||||
try {
|
||||
const sessionId = getPermissionSessionId(permission) || ""
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
||||
} catch (error) {
|
||||
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
|
||||
} finally {
|
||||
setPermissionBusy(permissionId, false)
|
||||
}
|
||||
}
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
|
||||
@@ -265,19 +303,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
return count === 1 ? "1 question" : `${count} questions`
|
||||
}
|
||||
|
||||
const handleActivate = () => {
|
||||
if (item.kind === "permission") {
|
||||
setActivePermissionIdForInstance(props.instanceId, item.id)
|
||||
} else {
|
||||
setActiveQuestionIdForInstance(props.instanceId, item.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
||||
role="listitem"
|
||||
onClick={handleActivate}
|
||||
>
|
||||
<div class="permission-center-item-header">
|
||||
<div class="permission-center-item-heading">
|
||||
@@ -315,17 +344,52 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={resolved()}
|
||||
fallback={
|
||||
<div class="permission-center-fallback">
|
||||
<div class="permission-center-fallback-title">
|
||||
<code>{primaryTitle()}</code>
|
||||
<Show
|
||||
when={resolved()}
|
||||
fallback={
|
||||
<div class="permission-center-fallback">
|
||||
<div class="permission-center-fallback-title">
|
||||
<code>{primaryTitle()}</code>
|
||||
</div>
|
||||
<Show when={item.kind === "permission"}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting().has(item.id)}
|
||||
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={permissionError().get(item.id)}>
|
||||
{(err) => <div class="tool-call-permission-error">{err()}</div>}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={item.kind !== "permission"}>
|
||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<ToolCall
|
||||
toolCall={data().toolPart}
|
||||
|
||||
@@ -604,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
}
|
||||
|
||||
setExpandState("normal")
|
||||
clearPrompt()
|
||||
|
||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||
@@ -843,7 +844,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
@@ -887,7 +891,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${filename}`
|
||||
const attachmentText = `@${normalizedPath}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
setPrompt(newPrompt)
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||
import { QuestionToolBlock } from "./tool-call/question-block"
|
||||
import { PermissionToolBlock } from "./tool-call/permission-block"
|
||||
import { createAnsiContentRenderer } from "./tool-call/ansi-render"
|
||||
import { createDiffContentRenderer } from "./tool-call/diff-render"
|
||||
import { createMarkdownContentRenderer } from "./tool-call/markdown-render"
|
||||
import { extractDiagnostics, diagnosticFileName } from "./tool-call/diagnostics"
|
||||
import { renderDiagnosticsSection } from "./tool-call/diagnostics-section"
|
||||
import type {
|
||||
DiffPayload,
|
||||
DiffRenderOptions,
|
||||
@@ -23,15 +27,11 @@ import type {
|
||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
import { escapeHtml } from "../lib/markdown"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -62,162 +62,7 @@ interface ToolCallProps {
|
||||
|
||||
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [
|
||||
input.filePath,
|
||||
metadata.filePath,
|
||||
metadata.filepath,
|
||||
input.path,
|
||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
|
||||
function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">
|
||||
:L{entry.line || "-"}:C{entry.column || "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
@@ -252,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return "noversion"
|
||||
})
|
||||
|
||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||
|
||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
@@ -269,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||
const ansiFinalCache = createVariantCache("ansi-final")
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||
const pendingPermission = createMemo(() => {
|
||||
@@ -554,15 +400,17 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission || !isPermissionActive()) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("once")
|
||||
void handlePermissionResponse(permission, "once")
|
||||
} else if (event.key === "a" || event.key === "A") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("always")
|
||||
void handlePermissionResponse(permission, "always")
|
||||
} else if (event.key === "d" || event.key === "D") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("reject")
|
||||
void handlePermissionResponse(permission, "reject")
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler)
|
||||
@@ -573,7 +421,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const [questionError, setQuestionError] = createSignal<string | null>(null)
|
||||
|
||||
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
|
||||
const [questionCustomDraft, setQuestionCustomDraft] = createSignal<Record<string, string[]>>({})
|
||||
|
||||
function isTextInputFocused() {
|
||||
const active = document.activeElement
|
||||
@@ -590,7 +437,10 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return
|
||||
}
|
||||
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
|
||||
const normalized = request.questions.map((_, index) => answers[index] ?? [])
|
||||
const normalized = request.questions.map((_, index) => {
|
||||
const row = answers[index] ?? []
|
||||
return row.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||
})
|
||||
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
||||
setQuestionError("Please answer all questions before submitting.")
|
||||
return
|
||||
@@ -684,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
const { renderAnsiContent } = createAnsiContentRenderer({
|
||||
ansiRunningCache,
|
||||
ansiFinalCache,
|
||||
scrollHelpers,
|
||||
partVersion: partVersionAccessor,
|
||||
})
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = cacheHandle.get<RenderCache>()
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
const { renderDiffContent } = createDiffContentRenderer({
|
||||
preferences,
|
||||
setDiffViewMode,
|
||||
isDark,
|
||||
diffCache,
|
||||
permissionDiffCache,
|
||||
scrollHelpers,
|
||||
handleScrollRendered,
|
||||
onContentRendered: props.onContentRendered,
|
||||
})
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
setDiffViewMode(mode)
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheHandle.params()}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAnsiContent(options: AnsiRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined
|
||||
const isRunningVariant = options.variant === "running"
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
if (isRunningVariant) {
|
||||
const content = options.content
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else {
|
||||
runningAnsiRenderer.reset()
|
||||
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
|
||||
}
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
|
||||
runningAnsiSource = nextCache.text
|
||||
cacheHandle.set(nextCache)
|
||||
} else {
|
||||
if (cached && cached.text === options.content) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else {
|
||||
const detectedAnsi = hasAnsi(options.content)
|
||||
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
|
||||
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
|
||||
cacheHandle.set(nextCache)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireAnsi && !nextCache.hasAnsi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
|
||||
const state = toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const partId = toolCallMemo()?.id
|
||||
if (!partId) {
|
||||
throw new Error("Tool call markdown requires a part id")
|
||||
}
|
||||
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
handleScrollRendered()
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||
const { renderMarkdownContent } = createMarkdownContentRenderer({
|
||||
toolState,
|
||||
partId: toolCallIdentifier,
|
||||
partVersion: partVersionAccessor,
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
isDark,
|
||||
scrollHelpers,
|
||||
handleScrollRendered,
|
||||
onContentRendered: props.onContentRendered,
|
||||
})
|
||||
|
||||
const rendererContext: ToolRendererContext = {
|
||||
toolCall: toolCallMemo,
|
||||
@@ -936,11 +630,8 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderer().renderBody(rendererContext)
|
||||
}
|
||||
|
||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
||||
const permission = permissionDetails()
|
||||
if (!permission || !isPermissionActive()) {
|
||||
return
|
||||
}
|
||||
async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
|
||||
if (!permission) return
|
||||
setPermissionSubmitting(true)
|
||||
setPermissionError(null)
|
||||
try {
|
||||
@@ -968,283 +659,33 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
|
||||
|
||||
const renderPermissionBlock = () => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) return null
|
||||
const active = isPermissionActive()
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
|
||||
const renderPermissionBlock = () => (
|
||||
<PermissionToolBlock
|
||||
permission={permissionDetails}
|
||||
active={isPermissionActive}
|
||||
submitting={permissionSubmitting}
|
||||
error={permissionError}
|
||||
renderDiff={renderDiffContent}
|
||||
fallbackSessionId={() => props.sessionId}
|
||||
onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
||||
</div>
|
||||
<Show when={diffPayload}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffContent(payload(), {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={active}
|
||||
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
|
||||
>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={permissionError()}>
|
||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderQuestionBlock = () => {
|
||||
const state = toolState()
|
||||
const request = questionDetails()
|
||||
const isQuestionTool = toolName() === "question"
|
||||
|
||||
if (!request && !isQuestionTool) return null
|
||||
|
||||
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||
const questions = Array.isArray(questionsSource) ? questionsSource : []
|
||||
if (questions.length === 0) return null
|
||||
|
||||
const requestId = request?.id ?? (state as any)?.input?.requestID ?? `question-${toolCallMemo()?.id ?? "unknown"}`
|
||||
const active = Boolean(request && isQuestionActive())
|
||||
|
||||
const completedAnswers = Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined
|
||||
const answers = completedAnswers ?? questionDraftAnswers()[requestId] ?? []
|
||||
const customInputs = questionCustomDraft()[requestId] ?? []
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!active) return
|
||||
setQuestionDraftAnswers((prev) => {
|
||||
const current = prev[requestId] ?? []
|
||||
const updated = [...current]
|
||||
updated[questionIndex] = next
|
||||
return { ...prev, [requestId]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const updateCustom = (questionIndex: number, value: string) => {
|
||||
if (!active) return
|
||||
setQuestionCustomDraft((prev) => {
|
||||
const current = prev[requestId] ?? []
|
||||
const updated = [...current]
|
||||
updated[questionIndex] = value
|
||||
return { ...prev, [requestId]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOption = (questionIndex: number, label: string) => {
|
||||
const info = questions[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
const existing = answers[questionIndex] ?? []
|
||||
if (multi) {
|
||||
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||
updateAnswer(questionIndex, next)
|
||||
return
|
||||
}
|
||||
updateAnswer(questionIndex, [label])
|
||||
}
|
||||
|
||||
const submitDisabled = () => {
|
||||
if (!active) return true
|
||||
if (questionSubmitting()) return true
|
||||
return questions.some((_, index) => (answers[index]?.length ?? 0) === 0)
|
||||
}
|
||||
|
||||
const showButtons = () => active
|
||||
|
||||
return (
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{active ? "Question Required" : request ? "Question Queued" : "Questions"}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{questions.length === 1 ? "Question" : "Questions"}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={questions}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
const multi = () => q?.multiple === true
|
||||
const selected = () => answers[i()] ?? []
|
||||
const customValue = () => customInputs[i()] ?? ""
|
||||
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||
const groupName = () => `question-${requestId}-${i()}`
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
<div class="text-xs text-muted">Multiple</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
{(opt) => {
|
||||
const checked = () => selected().includes(opt.label)
|
||||
return (
|
||||
<label
|
||||
class={`flex items-start gap-2 py-1 ${active ? "cursor-pointer" : request ? "opacity-80" : ""}`}
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={checked()}
|
||||
disabled={!active || questionSubmitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={active}>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
class="flex-1 rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
type="text"
|
||||
placeholder="Type your own answer"
|
||||
value={customValue()}
|
||||
disabled={!active || questionSubmitting()}
|
||||
onInput={(e) => updateCustom(i(), e.currentTarget.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={!active || questionSubmitting() || !customValue().trim()}
|
||||
onClick={() => {
|
||||
const value = customValue().trim()
|
||||
if (!value) return
|
||||
updateCustom(i(), value)
|
||||
toggleOption(i(), value)
|
||||
}}
|
||||
>
|
||||
{multi() ? "Toggle" : "Select"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={showButtons()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={submitDisabled()}
|
||||
onClick={() => handleQuestionSubmit()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={questionSubmitting()}
|
||||
onClick={() => handleQuestionDismiss()}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Submit</span>
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Dismiss</span>
|
||||
</div>
|
||||
|
||||
<Show when={questionError()}>
|
||||
<div class="tool-call-permission-error">{questionError()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!active && request}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderQuestionBlock = () => (
|
||||
<QuestionToolBlock
|
||||
toolName={toolName}
|
||||
toolState={toolState}
|
||||
toolCallId={toolCallIdentifier}
|
||||
request={questionDetails}
|
||||
active={isQuestionActive}
|
||||
submitting={questionSubmitting}
|
||||
error={questionError}
|
||||
draftAnswers={questionDraftAnswers}
|
||||
setDraftAnswers={setQuestionDraftAnswers}
|
||||
onSubmit={() => void handleQuestionSubmit()}
|
||||
onDismiss={() => void handleQuestionDismiss()}
|
||||
/>
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const request = questionDetails()
|
||||
@@ -1260,11 +701,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const initial = request.questions.map(() => [])
|
||||
return { ...prev, [requestId]: initial }
|
||||
})
|
||||
setQuestionCustomDraft((prev) => {
|
||||
if (prev[requestId]) return prev
|
||||
const initial = request.questions.map(() => "")
|
||||
return { ...prev, [requestId]: initial }
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const status = () => toolState()?.status || ""
|
||||
|
||||
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||
import { escapeHtml } from "../../lib/markdown"
|
||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
|
||||
type CacheHandle = {
|
||||
get<T>(): T | undefined
|
||||
set(value: unknown): void
|
||||
}
|
||||
|
||||
export function createAnsiContentRenderer(params: {
|
||||
ansiRunningCache: CacheHandle
|
||||
ansiFinalCache: CacheHandle
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
partVersion?: Accessor<number | undefined>
|
||||
}) {
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const getMode = () => {
|
||||
const version = params.partVersion?.()
|
||||
return typeof version === "number" ? String(version) : undefined
|
||||
}
|
||||
|
||||
function renderAnsiContent(options: AnsiRenderOptions): JSXElement | null {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const cacheHandle = options.variant === "running" ? params.ansiRunningCache : params.ansiFinalCache
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = getMode()
|
||||
const isRunningVariant = options.variant === "running"
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
if (isRunningVariant) {
|
||||
const content = options.content
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else {
|
||||
runningAnsiRenderer.reset()
|
||||
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
|
||||
}
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
|
||||
runningAnsiSource = nextCache.text
|
||||
cacheHandle.set(nextCache)
|
||||
} else {
|
||||
if (cached && cached.text === options.content) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else {
|
||||
const detectedAnsi = hasAnsi(options.content)
|
||||
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
|
||||
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
|
||||
cacheHandle.set(nextCache)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireAnsi && !nextCache.hasAnsi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{params.scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderAnsiContent }
|
||||
}
|
||||
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { DiagnosticEntry } from "./diagnostics"
|
||||
|
||||
export function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">
|
||||
🛠
|
||||
</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>
|
||||
{fileLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
export interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
||||
(value) => typeof value === "string" && value.length > 0,
|
||||
) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
export function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
106
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
106
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import type { DiffViewMode } from "../../stores/preferences"
|
||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
import { getCacheEntry } from "../../lib/global-cache"
|
||||
|
||||
type CacheHandle = {
|
||||
get<T>(): T | undefined
|
||||
params(): unknown
|
||||
}
|
||||
|
||||
type DiffPrefs = {
|
||||
diffViewMode?: DiffViewMode
|
||||
}
|
||||
|
||||
export function createDiffContentRenderer(params: {
|
||||
preferences: Accessor<DiffPrefs>
|
||||
setDiffViewMode: (mode: DiffViewMode) => void
|
||||
isDark: Accessor<boolean>
|
||||
diffCache: CacheHandle
|
||||
permissionDiffCache: CacheHandle
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = params.isDark() ? "dark" : "light"
|
||||
|
||||
const baseEntryParams = cacheHandle.params() as any
|
||||
const cacheEntryParams = (() => {
|
||||
const suffix = typeof options?.cacheKey === "string" ? options.cacheKey.trim() : ""
|
||||
if (!suffix) return baseEntryParams
|
||||
return {
|
||||
...baseEntryParams,
|
||||
cacheId: `${baseEntryParams.cacheId}:${suffix}`,
|
||||
}
|
||||
})()
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
params.setDiffViewMode(mode)
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
params.handleScrollRendered()
|
||||
}
|
||||
params.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderDiffContent }
|
||||
}
|
||||
76
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
76
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { TextPart } from "../../types/message"
|
||||
import { Markdown } from "../markdown"
|
||||
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
export function createMarkdownContentRenderer(params: {
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
partId: Accessor<string>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isDark: Accessor<boolean>
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const disableScrollTracking = options.disableScrollTracking || false
|
||||
|
||||
const state = params.toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheKey = typeof options.cacheKey === "string" && options.cacheKey.length > 0 ? options.cacheKey : undefined
|
||||
const markdownPart: TextPart = {
|
||||
id: cacheKey ? `${params.partId()}:${cacheKey}` : params.partId(),
|
||||
type: "text",
|
||||
text: options.content,
|
||||
version: params.partVersion?.(),
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
params.handleScrollRendered()
|
||||
params.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={params.instanceId}
|
||||
sessionId={params.sessionId}
|
||||
isDark={params.isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderMarkdownContent }
|
||||
}
|
||||
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Show, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import type { DiffPayload, DiffRenderOptions } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
|
||||
type PermissionResponse = "once" | "always" | "reject"
|
||||
|
||||
export type PermissionToolBlockProps = {
|
||||
permission: Accessor<PermissionRequestLike | undefined>
|
||||
active: Accessor<boolean>
|
||||
submitting: Accessor<boolean>
|
||||
error: Accessor<string | null>
|
||||
onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse) => void | Promise<void>
|
||||
renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null
|
||||
fallbackSessionId: Accessor<string>
|
||||
}
|
||||
|
||||
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
const diffPayload = () => {
|
||||
const permission = props.permission()
|
||||
if (!permission) return null
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
if (!diffValue || diffValue.trim().length === 0) return null
|
||||
return { diffText: diffValue, filePath: diffPathRaw } satisfies DiffPayload
|
||||
}
|
||||
|
||||
const respond = (response: PermissionResponse) => {
|
||||
const permission = props.permission()
|
||||
if (!permission) return
|
||||
const sessionId = getPermissionSessionId(permission) || props.fallbackSessionId()
|
||||
props.onRespond(permission, sessionId, response)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.permission()}>
|
||||
{(permission) => (
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{props.active() ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{getPermissionDisplayTitle(permission())}</code>
|
||||
</div>
|
||||
<Show when={diffPayload()}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{props.renderDiff(payload(), {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath
|
||||
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
|
||||
: "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!props.active()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||
</Show>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.error()}>
|
||||
<div class="tool-call-permission-error">{props.error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { createMemo, Show, For, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type QuestionOption = { label: string; description: string }
|
||||
|
||||
type QuestionPrompt = {
|
||||
header: string
|
||||
question: string
|
||||
options: QuestionOption[]
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export type QuestionToolBlockProps = {
|
||||
toolName: Accessor<string>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolCallId: Accessor<string>
|
||||
request: Accessor<QuestionRequest | undefined>
|
||||
active: Accessor<boolean>
|
||||
submitting: Accessor<boolean>
|
||||
error: Accessor<string | null>
|
||||
draftAnswers: Accessor<Record<string, string[][]>>
|
||||
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
||||
onSubmit: () => void | Promise<void>
|
||||
onDismiss: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const requestId = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}`
|
||||
})
|
||||
|
||||
const questions = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
if (!request && !isQuestionTool) return [] as QuestionPrompt[]
|
||||
|
||||
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||
const list = Array.isArray(questionsSource) ? questionsSource : []
|
||||
return list as QuestionPrompt[]
|
||||
})
|
||||
|
||||
const isVisible = createMemo(() => {
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
return Boolean(request) || isQuestionTool
|
||||
})
|
||||
|
||||
const answers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
|
||||
const completedAnswers =
|
||||
(state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers)
|
||||
? ((state as any).metadata.answers as string[][])
|
||||
: undefined
|
||||
|
||||
if (completedAnswers) return completedAnswers
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines)
|
||||
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) {
|
||||
return requestAnswers as string[][]
|
||||
}
|
||||
|
||||
const draft = props.draftAnswers()[requestId()] ?? []
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
const current = prev[requestId()] ?? []
|
||||
const updated = [...current]
|
||||
updated[questionIndex] = next
|
||||
return { ...prev, [requestId()]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOption = (questionIndex: number, label: string) => {
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
if (multi) {
|
||||
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||
updateAnswer(questionIndex, next)
|
||||
return
|
||||
}
|
||||
updateAnswer(questionIndex, [label])
|
||||
}
|
||||
|
||||
const submitDisabled = () => {
|
||||
if (!props.active()) return true
|
||||
if (props.submitting()) return true
|
||||
return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0)
|
||||
}
|
||||
|
||||
const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => {
|
||||
if (!props.active()) return
|
||||
const rawValue = input?.value ?? ""
|
||||
const value = rawValue
|
||||
if (value.trim().length === 0) return
|
||||
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
if (!multi) {
|
||||
// When switching a radio to custom, clear existing selection first.
|
||||
updateAnswer(questionIndex, [])
|
||||
}
|
||||
|
||||
toggleOption(questionIndex, value)
|
||||
}
|
||||
|
||||
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||
if (!props.active()) return
|
||||
if (valuesToRemove.length === 0) return
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const next = existing.filter((value) => !valuesToRemove.includes(value))
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => {
|
||||
if (!props.active()) return
|
||||
|
||||
const value = input.value
|
||||
const trimmed = value.trim()
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
|
||||
if (!multi) {
|
||||
updateAnswer(questionIndex, trimmed.length > 0 ? [value] : [])
|
||||
return
|
||||
}
|
||||
|
||||
const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label))
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const last = input.dataset.lastValue ?? ""
|
||||
|
||||
let next = existing.filter((item) => item !== last)
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
// Only treat it as custom if it doesn't match an existing option label.
|
||||
if (!optionLabels.has(trimmed) && !next.includes(value)) {
|
||||
next = [...next, value]
|
||||
} else if (optionLabels.has(trimmed)) {
|
||||
// If they typed an existing option label, don't treat it as custom.
|
||||
} else if (!next.includes(value)) {
|
||||
next = [...next, value]
|
||||
}
|
||||
input.dataset.lastValue = value
|
||||
} else {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
const multi = () => q?.multiple === true
|
||||
const selected = () => answers()[i()] ?? []
|
||||
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||
const groupName = () => `question-${requestId()}-${i()}`
|
||||
const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label))
|
||||
const customSelected = () => selected().filter((value) => !optionLabels().has(value))
|
||||
const customValue = () => customSelected()[0] ?? ""
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
<div class="text-xs text-muted">Multiple</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
{(opt) => {
|
||||
const checked = () => selected().includes(opt.label)
|
||||
return (
|
||||
<label
|
||||
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<label
|
||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title="Type a custom answer"
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
const container = e.currentTarget.closest("label")
|
||||
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
||||
if (!props.active()) return
|
||||
if (customChecked()) {
|
||||
clearCustomAnswer(i(), customSelected())
|
||||
if (input) {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
return
|
||||
}
|
||||
toggleFromCustomInput(i(), input)
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">Custom answer</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
type="text"
|
||||
placeholder="Type your own answer"
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
toggleFromCustomInput(i(), e.currentTarget)
|
||||
}}
|
||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={submitDisabled()}
|
||||
onClick={() => props.onSubmit()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => props.onDismiss()}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Submit</span>
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Dismiss</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.error()}>
|
||||
<div class="tool-call-permission-error">{props.error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal file
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import type { DiagnosticEntry } from "../diagnostics"
|
||||
|
||||
type LspRangePosition = {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
type LspRange = {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
type LspDiagnostic = {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
type ApplyPatchFile = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: string
|
||||
diff?: string
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function resolveDiagnosticsKey(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): string | undefined {
|
||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
||||
if (absolute && diagnostics[absolute]) return absolute
|
||||
if (relative && diagnostics[relative]) return relative
|
||||
|
||||
if (absolute) {
|
||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
||||
const normalized = normalizePath(key)
|
||||
return normalized === relative || normalized.endsWith("/" + relative)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||
if (!key) return []
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const normalizedKey = normalizePath(key)
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
|
||||
entries.push({
|
||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedKey,
|
||||
displayPath: getRelativePath(normalizedKey),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
|
||||
return (
|
||||
<Show when={props.entries.length > 0}>
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
|
||||
>
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={props.entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const applyPatchRenderer: ToolRenderer = {
|
||||
tools: ["apply_patch"],
|
||||
getAction: () => "Preparing apply_patch...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
if (state.status === "pending") return getToolName("apply_patch")
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
|
||||
if (files.length > 0) {
|
||||
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
|
||||
}
|
||||
return getToolName("apply_patch")
|
||||
},
|
||||
renderBody({ toolState, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const payload = readToolStatePayload(state)
|
||||
const files = createMemo(() => {
|
||||
const list = (payload.metadata as any).files
|
||||
return Array.isArray(list) ? (list as ApplyPatchFile[]) : []
|
||||
})
|
||||
const diagnosticsMap = createMemo(() => {
|
||||
const value = (payload.metadata as any).diagnostics
|
||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
||||
})
|
||||
|
||||
if (files().length === 0) {
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
if (!fallback) return null
|
||||
return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch">
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
|
||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch-file">
|
||||
<Show when={diffText.trim().length > 0}>
|
||||
{renderDiff(
|
||||
{ diffText, filePath },
|
||||
{
|
||||
label: `Diff · ${getRelativePath(labelBase)}`,
|
||||
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
||||
},
|
||||
)}
|
||||
</Show>
|
||||
<DiagnosticsInline entries={entries()} label={labelBase} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types"
|
||||
import { bashRenderer } from "./bash"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { editRenderer } from "./edit"
|
||||
import { applyPatchRenderer } from "./apply-patch"
|
||||
import { patchRenderer } from "./patch"
|
||||
import { readRenderer } from "./read"
|
||||
import { taskRenderer } from "./task"
|
||||
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
||||
readRenderer,
|
||||
writeRenderer,
|
||||
editRenderer,
|
||||
applyPatchRenderer,
|
||||
patchRenderer,
|
||||
webfetchRenderer,
|
||||
todoRenderer,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { getTodoTitle } from "./todo"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
@@ -90,7 +89,51 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
|
||||
const promptContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { input } = readToolStatePayload(state)
|
||||
const prompt = typeof input.prompt === "string" ? input.prompt : null
|
||||
return ensureMarkdownContent(prompt, undefined, false)
|
||||
})
|
||||
|
||||
const outputContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null
|
||||
return ensureMarkdownContent(output, undefined, false)
|
||||
})
|
||||
|
||||
const agentLabel = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { input } = readToolStatePayload(state)
|
||||
return typeof input.subagent_type === "string" ? input.subagent_type : null
|
||||
})
|
||||
|
||||
const modelLabel = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const model = (metadata as any).model
|
||||
if (!model || typeof model !== "object") return null
|
||||
const providerId = typeof model.providerID === "string" ? model.providerID : null
|
||||
const modelId = typeof model.modelID === "string" ? model.modelID : null
|
||||
if (!providerId && !modelId) return null
|
||||
if (providerId && modelId) return `${providerId}/${modelId}`
|
||||
return providerId ?? modelId
|
||||
})
|
||||
|
||||
const headerMeta = createMemo(() => {
|
||||
const agent = agentLabel()
|
||||
const model = modelLabel()
|
||||
if (agent && model) return `Agent: ${agent} • Model: ${model}`
|
||||
if (agent) return `Agent: ${agent}`
|
||||
if (model) return `Model: ${model}`
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
@@ -114,41 +157,90 @@ export const taskRenderer: ToolRenderer = {
|
||||
})
|
||||
})
|
||||
|
||||
if (items().length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="tool-call-task-sections">
|
||||
<Show when={promptContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Prompt</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
{renderMarkdown({
|
||||
content: promptContent()!,
|
||||
cacheKey: "task:prompt",
|
||||
disableScrollTracking: true,
|
||||
disableHighlight: true,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Steps</span>
|
||||
<span class="tool-call-task-section-meta">{items().length} steps</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={outputContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Output</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
{renderMarkdown({
|
||||
content: outputContent()!,
|
||||
cacheKey: "task:output",
|
||||
disableScrollTracking: true,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
|
||||
import { readRenderer } from "./renderers/read"
|
||||
import { writeRenderer } from "./renderers/write"
|
||||
import { editRenderer } from "./renderers/edit"
|
||||
import { applyPatchRenderer } from "./renderers/apply-patch"
|
||||
import { patchRenderer } from "./renderers/patch"
|
||||
import { webfetchRenderer } from "./renderers/webfetch"
|
||||
import { todoRenderer } from "./renderers/todo"
|
||||
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||
read: readRenderer,
|
||||
write: writeRenderer,
|
||||
edit: editRenderer,
|
||||
apply_patch: applyPatchRenderer,
|
||||
patch: patchRenderer,
|
||||
webfetch: webfetchRenderer,
|
||||
todowrite: todoRenderer,
|
||||
|
||||
@@ -13,6 +13,16 @@ export interface MarkdownRenderOptions {
|
||||
content: string
|
||||
size?: "default" | "large"
|
||||
disableHighlight?: boolean
|
||||
/**
|
||||
* Optional suffix to avoid render-cache collisions when a tool call renders
|
||||
* multiple markdown regions (e.g. task prompt vs task output).
|
||||
*/
|
||||
cacheKey?: string
|
||||
/**
|
||||
* When true, do not register this markdown region with tool-call scroll
|
||||
* tracking (avoids nested scroll + autoscroll interactions).
|
||||
*/
|
||||
disableScrollTracking?: boolean
|
||||
}
|
||||
|
||||
export interface AnsiRenderOptions {
|
||||
@@ -26,6 +36,11 @@ export interface DiffRenderOptions {
|
||||
variant?: string
|
||||
disableScrollTracking?: boolean
|
||||
label?: string
|
||||
/**
|
||||
* Optional cache key suffix to avoid collisions when rendering multiple diffs
|
||||
* within the same tool call (e.g. apply_patch).
|
||||
*/
|
||||
cacheKey?: string
|
||||
}
|
||||
|
||||
export interface ToolScrollHelpers {
|
||||
|
||||
@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
case "apply_patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
case "apply_patch":
|
||||
return "Apply patch"
|
||||
default: {
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
case "apply_patch":
|
||||
return "Preparing apply_patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
@@ -534,7 +534,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<div>
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Enter</span> select •{" "}
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Tab/Enter</span> select •{" "}
|
||||
<span class="font-medium">Esc</span> close
|
||||
</div>
|
||||
</div>
|
||||
|
||||
38
packages/ui/src/components/version-pill.tsx
Normal file
38
packages/ui/src/components/version-pill.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Show, createEffect, createSignal } from "solid-js"
|
||||
import type { ServerMeta } from "../../../server/src/api-types"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
|
||||
export default function VersionPill() {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
void getServerMeta()
|
||||
.then((result) => setMeta(result))
|
||||
.catch(() => setMeta(null))
|
||||
})
|
||||
|
||||
const serverVersion = () => meta()?.serverVersion
|
||||
const uiVersion = () => meta()?.ui?.version
|
||||
const uiSource = () => meta()?.ui?.source
|
||||
|
||||
return (
|
||||
<Show when={serverVersion() || uiVersion() || uiSource()}>
|
||||
<div class="text-[11px] text-muted whitespace-nowrap">
|
||||
<Show when={serverVersion()}>
|
||||
{(v) => <span>App {v()}</span>}
|
||||
</Show>
|
||||
<Show when={uiVersion() || uiSource()}>
|
||||
<>
|
||||
<Show when={serverVersion()}>
|
||||
<span class="mx-2">·</span>
|
||||
</Show>
|
||||
<span>
|
||||
UI{uiVersion() ? ` ${uiVersion()}` : ""}
|
||||
<Show when={uiSource()}>{(s) => <span class="opacity-70"> ({s()})</span>}</Show>
|
||||
</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
FileSystemEntry,
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
@@ -224,6 +225,13 @@ export const serverApi = {
|
||||
const query = params.toString()
|
||||
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
||||
},
|
||||
|
||||
createFileSystemFolder(parentPath: string | undefined, name: string): Promise<FileSystemCreateFolderResponse> {
|
||||
return request<FileSystemCreateFolderResponse>("/api/filesystem/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentPath, name }),
|
||||
})
|
||||
},
|
||||
readInstanceData(id: string): Promise<InstanceData> {
|
||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
@@ -92,6 +92,19 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
}
|
||||
}
|
||||
|
||||
function ensureActiveInstanceSelected(): void {
|
||||
const current = activeInstanceId()
|
||||
const instanceMap = instances()
|
||||
if (current && instanceMap.has(current)) return
|
||||
|
||||
for (const [id, instance] of instanceMap.entries()) {
|
||||
if (instance.status === "ready") {
|
||||
setActiveInstanceId(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
const mapped = workspaceDescriptorToInstance(descriptor)
|
||||
if (instances().has(descriptor.id)) {
|
||||
@@ -102,6 +115,9 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
|
||||
if (descriptor.status === "ready") {
|
||||
attachClient(descriptor)
|
||||
// If no tab is currently selected (common after UI refresh),
|
||||
// auto-select the first ready instance.
|
||||
ensureActiveInstanceSelected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,15 +241,18 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
// After a UI refresh, we may have instances but no active selection.
|
||||
ensureActiveInstanceSelected()
|
||||
} catch (error) {
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
|
||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import type { SupportMeta } from "../../../server/src/api-types"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { showToastNotification, ToastHandle } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { hasInstances, showFolderSelection } from "./ui"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
|
||||
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
||||
|
||||
let initialized = false
|
||||
let visibilityEffectInitialized = false
|
||||
let activeToast: ToastHandle | null = null
|
||||
let activeToastVersion: string | null = null
|
||||
let activeToastKey: string | null = null
|
||||
|
||||
function dismissActiveToast() {
|
||||
if (activeToast) {
|
||||
activeToast.dismiss()
|
||||
activeToast = null
|
||||
activeToastVersion = null
|
||||
activeToastKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,28 +29,34 @@ function ensureVisibilityEffect() {
|
||||
visibilityEffectInitialized = true
|
||||
|
||||
createEffect(() => {
|
||||
const release = availableRelease()
|
||||
const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection())
|
||||
const support = supportInfo()
|
||||
const shouldShow = Boolean(support && support.supported === false) && (!hasInstances() || showFolderSelection())
|
||||
|
||||
if (!shouldShow || !release) {
|
||||
if (!shouldShow || !support || support.supported !== false) {
|
||||
dismissActiveToast()
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeToast || activeToastVersion !== release.version) {
|
||||
const key = `${support.minServerVersion ?? "unknown"}:${support.latestServerVersion ?? "unknown"}`
|
||||
|
||||
if (!activeToast || activeToastKey !== key) {
|
||||
dismissActiveToast()
|
||||
activeToast = showToastNotification({
|
||||
title: `CodeNomad ${release.version}`,
|
||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
||||
title: support.message ?? "Upgrade required",
|
||||
message: support.latestServerVersion
|
||||
? `Update to CodeNomad ${support.latestServerVersion} to use the latest UI.`
|
||||
: "Update CodeNomad to use the latest UI.",
|
||||
variant: "info",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
position: "bottom-right",
|
||||
action: {
|
||||
label: "View release",
|
||||
href: release.url,
|
||||
},
|
||||
action: support.latestServerUrl
|
||||
? {
|
||||
label: "Get update",
|
||||
href: support.latestServerUrl,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
activeToastVersion = release.version
|
||||
activeToastKey = key
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -64,32 +69,17 @@ export function initReleaseNotifications() {
|
||||
|
||||
ensureVisibilityEffect()
|
||||
void refreshFromMeta()
|
||||
|
||||
serverEvents.on("app.releaseAvailable", (event) => {
|
||||
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
|
||||
applyRelease(typedEvent.release)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshFromMeta() {
|
||||
try {
|
||||
const meta = await getServerMeta(true)
|
||||
if (meta.latestRelease) {
|
||||
applyRelease(meta.latestRelease)
|
||||
}
|
||||
setSupportInfo(meta.support ?? null)
|
||||
} catch (error) {
|
||||
log.warn("Unable to load server metadata for release info", error)
|
||||
log.warn("Unable to load server metadata for support info", error)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRelease(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
setAvailableRelease(null)
|
||||
return
|
||||
}
|
||||
setAvailableRelease(release)
|
||||
}
|
||||
|
||||
export function useAvailableRelease() {
|
||||
return availableRelease
|
||||
export function useSupportInfo() {
|
||||
return supportInfo
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { loadMessages } from "./session-api"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
reconcilePendingQuestionsV2,
|
||||
upsertMessageInfoV2,
|
||||
upsertPermissionV2,
|
||||
upsertQuestionV2,
|
||||
@@ -230,6 +231,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
|
||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||
|
||||
if (part.type === "tool" && part.tool === "question") {
|
||||
// Questions can arrive before their tool part exists; re-link now.
|
||||
reconcilePendingQuestionsV2(instanceId, sessionId)
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
} else if (event.type === "message.updated") {
|
||||
|
||||
@@ -81,6 +81,14 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.directory-browser-current-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.directory-browser-close {
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
@@ -217,6 +217,16 @@
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Diff shell already provides the scroll container.
|
||||
Avoid nested scroll areas inside the diff viewer. */
|
||||
.tool-call-diff-shell .tool-call-diff-viewer {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar-label {
|
||||
@@ -423,6 +433,19 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* apply_patch multi-file layout */
|
||||
.tool-call-apply-patch {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.tool-call-apply-patch-file {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-call-apply-patch-file:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
@@ -1,7 +1,74 @@
|
||||
.tool-call-task-container {
|
||||
.tool-call-task-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-task-section {
|
||||
border: 1px solid var(--border-base);
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tool-call-task-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-task-section-title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.tool-call-task-section-meta {
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-task-section-body {
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-task-section-body .tool-call-markdown {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tool-call-task-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Steps list should be flush (no inset padding). */
|
||||
.tool-call-task-section-body .tool-call-task-container.tool-call-markdown {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Keep task lists compact vs prompt/output panes. */
|
||||
.tool-call-task-container.tool-call-markdown {
|
||||
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) / 2);
|
||||
}
|
||||
|
||||
/* Prompt + output panes: slightly taller than tasks. */
|
||||
.tool-call-task-section-body > .tool-call-markdown:not(.tool-call-task-container) {
|
||||
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) * 2 / 3);
|
||||
}
|
||||
|
||||
.tool-call-task-empty {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -103,6 +103,25 @@
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 text-sm;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import fs from "fs"
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
|
||||
const uiVersion = uiPackageJson.version ?? "0.0.0"
|
||||
|
||||
export default defineConfig({
|
||||
root: "./src/renderer",
|
||||
plugins: [solid()],
|
||||
plugins: [
|
||||
solid(),
|
||||
{
|
||||
name: "emit-ui-version",
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "ui-version.json",
|
||||
source: JSON.stringify({ uiVersion }, null, 2),
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user