Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 | ||
|
|
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 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc | ||
|
|
37da426ab4 | ||
|
|
591f55bef9 | ||
|
|
aabaadbe1d | ||
|
|
3ab14e8de6 | ||
|
|
40634138bc | ||
|
|
b17087b610 | ||
|
|
71f58e7c5f | ||
|
|
927e4e1281 | ||
|
|
2e56a5e9f4 | ||
|
|
296d07a0d6 | ||
|
|
0d8a844af8 | ||
|
|
bf9cef4cd5 | ||
|
|
9dde33aba7 | ||
|
|
0fefff3b0a | ||
|
|
1122c19648 | ||
|
|
f06359a1fc | ||
|
|
72f420b6f6 |
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/main.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
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/
|
||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Creates release notes
|
||||
agent: build
|
||||
---
|
||||
|
||||
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -1096,9 +1096,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
|
||||
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==",
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -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.6.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7423,7 +7418,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"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,18 +7455,18 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.1",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"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.9.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,4 +1,6 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function isBootstrapTokenUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||
// consuming the token in the hidden view and then failing in the main window.
|
||||
if (isBootstrapTokenUrl(url)) {
|
||||
finalizeCliSwap(url)
|
||||
return
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||
if (!raw) return null
|
||||
|
||||
const first = raw.split(";")[0] ?? ""
|
||||
const index = first.indexOf("=")
|
||||
if (index < 0) return null
|
||||
|
||||
const key = first.slice(0, index).trim()
|
||||
const value = first.slice(index + 1).trim()
|
||||
if (key !== name || !value) return null
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
const transport = target.protocol === "https:" ? https : http
|
||||
|
||||
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
target,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||
},
|
||||
)
|
||||
|
||||
req.on("error", reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
|
||||
if (result.statusCode !== 200) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
@@ -323,11 +411,53 @@ async function startCli() {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||
if (bootstrapExchangeInFlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = pendingBootstrapToken
|
||||
if (!token) {
|
||||
startCliPreload(baseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
bootstrapExchangeInFlight = true
|
||||
|
||||
try {
|
||||
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||
pendingBootstrapToken = null
|
||||
|
||||
if (!ok) {
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
return
|
||||
}
|
||||
|
||||
startCliPreload(baseUrl)
|
||||
} catch (error) {
|
||||
console.error("[cli] bootstrap token exchange failed:", error)
|
||||
pendingBootstrapToken = null
|
||||
startCliPreload(`${baseUrl}/login`)
|
||||
} finally {
|
||||
bootstrapExchangeInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("bootstrapToken", (token) => {
|
||||
pendingBootstrapToken = token
|
||||
|
||||
const status = cliManager.getStatus()
|
||||
if (status.url) {
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
startCliPreload(status.url)
|
||||
|
||||
void maybeExchangeAndNavigate(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
@@ -173,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
@@ -227,11 +234,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
console.info(`[cli][${stream}] ${line}`)
|
||||
this.emit("log", { stream, message: line })
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const port = this.extractPort(line)
|
||||
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||
if (token && !this.bootstrapToken) {
|
||||
this.bootstrapToken = token
|
||||
this.emit("bootstrapToken", token)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const port = this.extractPort(trimmed)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
@@ -271,7 +289,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
@@ -361,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"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.8"
|
||||
"@opencode-ai/plugin": "1.1.30"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||
|
||||
type BackgroundProcess = {
|
||||
id: string
|
||||
@@ -12,11 +13,6 @@ type BackgroundProcess = {
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
@@ -27,30 +23,10 @@ type ParsedCommand = {
|
||||
}
|
||||
|
||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
|
||||
const base = config.baseUrl.replace(/\/+$/, "")
|
||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
|
||||
|
||||
if (char === "|" || char === "&" || char === ";") {
|
||||
flush()
|
||||
const next = input[index + 1]
|
||||
if ((char === "|" || char === "&") && next === char) {
|
||||
tokens.push(char + next)
|
||||
index += 1
|
||||
} else {
|
||||
tokens.push(char)
|
||||
}
|
||||
tokens.push(char)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isSeparator(token: string) {
|
||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
||||
function isSeparator(token: string): boolean {
|
||||
return token === "|" || token === "&" || token === ";"
|
||||
}
|
||||
|
||||
function unquote(value: string) {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
function unquote(token: string): string {
|
||||
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||
return token.slice(1, -1)
|
||||
}
|
||||
return value
|
||||
return token
|
||||
}
|
||||
|
||||
function isWithinBase(baseDir: string, target: string) {
|
||||
const relative = path.relative(baseDir, target)
|
||||
if (!relative) return true
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
function isWithinBase(base: string, candidate: string): boolean {
|
||||
const relative = path.relative(base, candidate)
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
@@ -1,74 +1,41 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||
|
||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||
return {
|
||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
||||
}
|
||||
}
|
||||
const requester = createCodeNomadRequester(config)
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
return {
|
||||
postEvent: (event: PluginEvent) =>
|
||||
requester.requestVoid("/event", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
||||
|
||||
async function startPluginEvents(
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
) {
|
||||
// Fail plugin startup if we cannot establish the initial connection.
|
||||
const initialBody = await connectWithRetries(url, 3)
|
||||
const initialBody = await connectWithRetries(requester, 3)
|
||||
|
||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||
void consumeWithReconnect(url, onEvent, initialBody)
|
||||
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||
}
|
||||
|
||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body
|
||||
return await requester.requestSseBody("/events")
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await delay(500 * attempt)
|
||||
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
}
|
||||
|
||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
||||
const url = requester.buildUrl("/events")
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||
}
|
||||
|
||||
async function consumeWithReconnect(
|
||||
url: string,
|
||||
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
initialBody: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
|
||||
while (true) {
|
||||
try {
|
||||
if (!body) {
|
||||
body = await connectWithRetries(url, 3)
|
||||
body = await connectWithRetries(requester, 3)
|
||||
}
|
||||
|
||||
await consumeSseBody(body, onEvent)
|
||||
|
||||
124
packages/opencode-config/plugin/lib/request.ts
Normal file
124
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
const buildUrl = (path: string) => {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${pluginBase}${normalized}`
|
||||
}
|
||||
|
||||
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||
const output: Record<string, string> = normalizeHeaders(headers)
|
||||
output.Authorization = authorization
|
||||
if (hasBody) {
|
||||
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||
const url = buildUrl(path)
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||
const response = await fetchWithAuth(path, init)
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body as ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
fetch: fetchWithAuth,
|
||||
requestJson,
|
||||
requestVoid,
|
||||
requestSseBody,
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function buildInstanceAuthorizationHeader(): string {
|
||||
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
774
packages/server/package-lock.json
generated
774
packages/server/package-lock.json
generated
@@ -1,20 +1,30 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"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.6.0",
|
||||
"version": "0.9.1",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
@@ -16,11 +16,11 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -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",
|
||||
|
||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
|
||||
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
|
||||
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||
@@ -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"
|
||||
|
||||
175
packages/server/src/auth/auth-store.ts
Normal file
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
|
||||
export interface AuthFile {
|
||||
version: 1
|
||||
username: string
|
||||
password: PasswordHashRecord
|
||||
userProvided: boolean
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
username: string
|
||||
passwordUserProvided: boolean
|
||||
}
|
||||
|
||||
export class AuthStore {
|
||||
private cachedFile: AuthFile | null = null
|
||||
private overrideAuth: AuthFile | null = null
|
||||
private bootstrapUsername: string | null = null
|
||||
|
||||
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||
|
||||
getAuthFilePath() {
|
||||
return this.authFilePath
|
||||
}
|
||||
|
||||
load(): AuthFile | null {
|
||||
if (this.overrideAuth) {
|
||||
return this.overrideAuth
|
||||
}
|
||||
|
||||
if (this.cachedFile) {
|
||||
return this.cachedFile
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.authFilePath)) {
|
||||
return null
|
||||
}
|
||||
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||
const parsed = JSON.parse(raw) as AuthFile
|
||||
if (!parsed || parsed.version !== 1) {
|
||||
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||
return null
|
||||
}
|
||||
this.cachedFile = parsed
|
||||
return parsed
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ensureInitialized(params: {
|
||||
username: string
|
||||
password?: string
|
||||
allowBootstrapWithoutPassword: boolean
|
||||
}): void {
|
||||
const password = params.password?.trim()
|
||||
if (password) {
|
||||
const now = new Date().toISOString()
|
||||
const runtime: AuthFile = {
|
||||
version: 1,
|
||||
username: params.username,
|
||||
password: hashPassword(password),
|
||||
userProvided: true,
|
||||
updatedAt: now,
|
||||
}
|
||||
this.overrideAuth = runtime
|
||||
this.cachedFile = null
|
||||
this.bootstrapUsername = null
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||
return
|
||||
}
|
||||
|
||||
const existing = this.load()
|
||||
if (existing) {
|
||||
if (existing.username !== params.username) {
|
||||
// Keep existing username unless explicitly overridden later.
|
||||
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||
}
|
||||
this.bootstrapUsername = null
|
||||
return
|
||||
}
|
||||
|
||||
if (params.allowBootstrapWithoutPassword) {
|
||||
this.bootstrapUsername = params.username
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||
)
|
||||
}
|
||||
|
||||
validateCredentials(username: string, password: string): boolean {
|
||||
const auth = this.load()
|
||||
if (!auth) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (username !== auth.username) {
|
||||
return false
|
||||
}
|
||||
|
||||
return verifyPassword(password, auth.password)
|
||||
}
|
||||
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||
if (this.overrideAuth) {
|
||||
throw new Error(
|
||||
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||
)
|
||||
}
|
||||
|
||||
const current = this.load()
|
||||
|
||||
if (!current) {
|
||||
if (!this.bootstrapUsername) {
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
const created: AuthFile = {
|
||||
version: 1,
|
||||
username: this.bootstrapUsername,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(created)
|
||||
this.bootstrapUsername = null
|
||||
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||
}
|
||||
|
||||
const next: AuthFile = {
|
||||
...current,
|
||||
password: hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.persist(next)
|
||||
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||
}
|
||||
|
||||
getStatus(): AuthStatus {
|
||||
const current = this.load()
|
||||
if (current) {
|
||||
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||
}
|
||||
|
||||
if (this.bootstrapUsername) {
|
||||
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||
}
|
||||
|
||||
throw new Error("Auth is not initialized")
|
||||
}
|
||||
|
||||
private persist(auth: AuthFile) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||
this.cachedFile = auth
|
||||
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/server/src/auth/http-auth.ts
Normal file
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!header) return result
|
||||
|
||||
const parts = header.split(";")
|
||||
for (const part of parts) {
|
||||
const index = part.indexOf("=")
|
||||
if (index < 0) continue
|
||||
const key = part.slice(0, index).trim()
|
||||
const value = part.slice(index + 1).trim()
|
||||
if (!key) continue
|
||||
result[key] = decodeURIComponent(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||
if (!remoteAddress) return false
|
||||
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function wantsHtml(request: FastifyRequest): boolean {
|
||||
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||
}
|
||||
|
||||
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
113
packages/server/src/auth/manager.ts
Normal file
113
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { AuthStore } from "./auth-store"
|
||||
import { TokenManager } from "./token-manager"
|
||||
import { SessionManager } from "./session-manager"
|
||||
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||
|
||||
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||
|
||||
export interface AuthManagerInit {
|
||||
configPath: string
|
||||
username: string
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||
|
||||
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||
this.authStore.ensureInitialized({
|
||||
username: init.username,
|
||||
password: init.password,
|
||||
allowBootstrapWithoutPassword: init.generateToken,
|
||||
})
|
||||
|
||||
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||
}
|
||||
|
||||
getCookieName(): string {
|
||||
return this.cookieName
|
||||
}
|
||||
|
||||
isTokenBootstrapEnabled(): boolean {
|
||||
return Boolean(this.tokenManager)
|
||||
}
|
||||
|
||||
issueBootstrapToken(): string | null {
|
||||
if (!this.tokenManager) return null
|
||||
return this.tokenManager.generate()
|
||||
}
|
||||
|
||||
consumeBootstrapToken(token: string): boolean {
|
||||
if (!this.tokenManager) return false
|
||||
return this.tokenManager.consume(token)
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
return this.authStore.validateCredentials(username, password)
|
||||
}
|
||||
|
||||
createSession(username: string) {
|
||||
return this.sessionManager.createSession(username)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.authStore.getStatus()
|
||||
}
|
||||
|
||||
setPassword(password: string) {
|
||||
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||
}
|
||||
|
||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||
return isLoopbackAddress(request.socket.remoteAddress)
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
return { username: session.username, sessionId: session.id }
|
||||
}
|
||||
|
||||
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
return parts.join("; ")
|
||||
}
|
||||
49
packages/server/src/auth/password-hash.ts
Normal file
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface PasswordHashRecord {
|
||||
algorithm: "scrypt"
|
||||
saltBase64: string
|
||||
hashBase64: string
|
||||
keyLength: number
|
||||
params: {
|
||||
N: number
|
||||
r: number
|
||||
p: number
|
||||
maxmem: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SCRYPT_PARAMS = {
|
||||
N: 16384,
|
||||
r: 8,
|
||||
p: 1,
|
||||
maxmem: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): PasswordHashRecord {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||
return {
|
||||
algorithm: "scrypt",
|
||||
saltBase64: salt.toString("base64"),
|
||||
hashBase64: Buffer.from(derived).toString("base64"),
|
||||
keyLength,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||
if (record.algorithm !== "scrypt") {
|
||||
return false
|
||||
}
|
||||
|
||||
const salt = Buffer.from(record.saltBase64, "base64")
|
||||
const expected = Buffer.from(record.hashBase64, "base64")
|
||||
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||
if (expected.length !== derived.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||
}
|
||||
23
packages/server/src/auth/session-manager.ts
Normal file
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
createdAt: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessions = new Map<string, SessionInfo>()
|
||||
|
||||
createSession(username: string): SessionInfo {
|
||||
const id = crypto.randomBytes(32).toString("base64url")
|
||||
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||
this.sessions.set(id, info)
|
||||
return info
|
||||
}
|
||||
|
||||
getSession(id: string | undefined): SessionInfo | undefined {
|
||||
if (!id) return undefined
|
||||
return this.sessions.get(id)
|
||||
}
|
||||
}
|
||||
32
packages/server/src/auth/token-manager.ts
Normal file
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface BootstrapToken {
|
||||
token: string
|
||||
createdAt: number
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
export class TokenManager {
|
||||
private token: BootstrapToken | null = null
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
generate(): string {
|
||||
const token = crypto.randomBytes(32).toString("base64url")
|
||||
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||
return token
|
||||
}
|
||||
|
||||
consume(token: string): boolean {
|
||||
if (!this.token) return false
|
||||
if (this.token.consumed) return false
|
||||
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||
if (token !== this.token.token) return false
|
||||
this.token.consumed = true
|
||||
return true
|
||||
}
|
||||
|
||||
peek(): string | null {
|
||||
return this.token?.token ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { randomBytes } from "crypto"
|
||||
@@ -60,10 +60,13 @@ export class BackgroundProcessManager {
|
||||
|
||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||
|
||||
const child = spawn("bash", ["-c", command], {
|
||||
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||
|
||||
const child = spawn(shellCommand, shellArgs, {
|
||||
cwd: workspace.path,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
...spawnOptions,
|
||||
})
|
||||
|
||||
child.on("exit", () => {
|
||||
@@ -274,7 +277,15 @@ export class BackgroundProcessManager {
|
||||
const pid = child.pid
|
||||
if (!pid) return
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
if (process.platform === "win32") {
|
||||
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||
try {
|
||||
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||
return
|
||||
} catch {
|
||||
// Fall back to killing the direct child.
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
@@ -321,6 +332,30 @@ export class BackgroundProcessManager {
|
||||
}
|
||||
|
||||
|
||||
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||
if (process.platform === "win32") {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
return {
|
||||
shellCommand: comspec,
|
||||
shellArgs: ["/d", "/s", "/c", command],
|
||||
spawnOptions: { windowsVerbatimArguments: true },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep bash for macOS/Linux.
|
||||
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||
}
|
||||
|
||||
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||
const force = signal === "SIGKILL"
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { spawnSync } from "child_process"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(
|
||||
@@ -135,8 +137,42 @@ export class BinaryRegistry {
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
// TODO: call actual binary -v check.
|
||||
return { valid: true, version: record.version }
|
||||
const inputPath = record.path
|
||||
if (!inputPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(inputPath, ["--version"])
|
||||
|
||||
try {
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdout = (result.stdout ?? "").trim()
|
||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||
const normalized = firstLine?.trim()
|
||||
|
||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
const version = versionMatch?.[1]
|
||||
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
|
||||
@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
|
||||
@@ -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,8 @@ 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)
|
||||
|
||||
@@ -36,7 +37,13 @@ interface CliOptions {
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
uiAutoUpdate: boolean
|
||||
uiNoUpdate: boolean
|
||||
uiManifestUrl?: string
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
generateToken: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
@@ -62,7 +69,21 @@ 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")
|
||||
.env("CODENOMAD_SERVER_USERNAME")
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
.default(false),
|
||||
)
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
@@ -76,13 +97,22 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
uiNoUpdate?: boolean
|
||||
uiAutoUpdate?: string
|
||||
uiManifestUrl?: string
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
generateToken?: boolean
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
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,
|
||||
@@ -93,7 +123,13 @@ 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,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,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() {
|
||||
@@ -119,21 +167,45 @@ async function main() {
|
||||
const configLogger = logger.child({ component: "config" })
|
||||
const eventLogger = logger.child({ component: "events" })
|
||||
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
const logOptions = {
|
||||
...options,
|
||||
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||
}
|
||||
|
||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||
|
||||
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,
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const authManager = new AuthManager(
|
||||
{
|
||||
configPath: options.configPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
generateToken: options.generateToken,
|
||||
},
|
||||
logger.child({ component: "auth" }),
|
||||
)
|
||||
|
||||
if (options.generateToken) {
|
||||
const token = authManager.issueBootstrapToken()
|
||||
if (token) {
|
||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||
}
|
||||
}
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
@@ -152,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,
|
||||
@@ -175,8 +264,9 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
|
||||
@@ -196,23 +286,35 @@ async function main() {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
const shutdownWorkspaces = (async () => {
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
releaseMonitor.stop()
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
})()
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
|
||||
@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
@@ -34,6 +37,7 @@ interface HttpServerDeps {
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -88,8 +92,42 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
done()
|
||||
})
|
||||
|
||||
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: true,
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
let selfOrigin: string | null = null
|
||||
try {
|
||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||
} catch {
|
||||
selfOrigin = null
|
||||
}
|
||||
|
||||
if (selfOrigin && origin === selfOrigin) {
|
||||
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)
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
@@ -109,6 +147,76 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
app.addHook("preHandler", (request, reply, done) => {
|
||||
const rawUrl = request.raw.url ?? request.url
|
||||
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
||||
|
||||
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
||||
const publicPagePaths = new Set(["/login"])
|
||||
if (deps.authManager.isTokenBootstrapEnabled()) {
|
||||
publicPagePaths.add("/auth/token")
|
||||
}
|
||||
|
||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
if (pluginMatch) {
|
||||
const workspaceId = pluginMatch[1]
|
||||
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
const provided = Array.isArray(request.headers.authorization)
|
||||
? request.headers.authorization[0]
|
||||
: request.headers.authorization
|
||||
|
||||
if (expected && provided && provided === expected) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendUnauthorized(request, reply)
|
||||
return
|
||||
}
|
||||
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
done()
|
||||
})
|
||||
|
||||
app.get("/", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
||||
return
|
||||
}
|
||||
|
||||
const uiDir = deps.uiStaticDir
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
if (uiDir && fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(404).send({ message: "UI bundle missing" })
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
@@ -125,9 +233,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
||||
} else {
|
||||
setupStaticUi(app, deps.uiStaticDir)
|
||||
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -175,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}`)
|
||||
|
||||
@@ -260,6 +368,7 @@ async function proxyWorkspaceRequest(args: {
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
@@ -267,6 +376,12 @@ async function proxyWorkspaceRequest(args: {
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!proxyReply.sent) {
|
||||
@@ -284,7 +399,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
return
|
||||
@@ -310,6 +425,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromRequest(request)
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
} else {
|
||||
@@ -318,7 +439,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
@@ -326,6 +447,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromRequest(request)
|
||||
if (!session && wantsHtml(request)) {
|
||||
reply.redirect("/login")
|
||||
return
|
||||
}
|
||||
|
||||
void proxyToDevServer(request, reply, upstreamBase)
|
||||
})
|
||||
}
|
||||
|
||||
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad Login</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: #0b0b0f;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: #14141c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 18px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin: 10px 0 6px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: #0f0f16;
|
||||
color: #fff;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: #4c6fff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||
|
||||
<button id="submit" type="button">Continue</button>
|
||||
<div id="error" class="error" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const errorEl = $("error")
|
||||
const showError = (msg) => {
|
||||
errorEl.textContent = msg
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
const hideError = () => {
|
||||
errorEl.textContent = ""
|
||||
errorEl.style.display = "none"
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
hideError()
|
||||
const username = $("username").value.trim()
|
||||
const password = $("password").value
|
||||
if (!username || !password) {
|
||||
showError("Username and password are required.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || `Login failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
window.location.href = "/"
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
$("submit").addEventListener("click", submit)
|
||||
$("password").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: #0b0b0f;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: #14141c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connecting…</h1>
|
||||
<p>Finalizing local authentication.</p>
|
||||
<div id="error" class="error" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = (location.hash || "").replace(/^#/, "").trim()
|
||||
const errorEl = document.getElementById("error")
|
||||
const showError = (msg) => {
|
||||
errorEl.textContent = msg
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!token) {
|
||||
showError("Missing bootstrap token.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || `Token exchange failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.replace("/")
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
157
packages/server/src/server/routes/auth.ts
Normal file
157
packages/server/src/server/routes/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fs from "fs"
|
||||
import { z } from "zod"
|
||||
import type { AuthManager } from "../../auth/manager"
|
||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||
|
||||
interface RouteDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
const TokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||
|
||||
let cachedLoginTemplate: string | null = null
|
||||
let cachedTokenTemplate: string | null = null
|
||||
|
||||
function readTemplate(url: URL, cache: string | null): string {
|
||||
if (cache) return cache
|
||||
const content = fs.readFileSync(url, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function getLoginHtml(defaultUsername: string): string {
|
||||
if (!cachedLoginTemplate) {
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||
}
|
||||
|
||||
const escapedUsername = escapeHtml(defaultUsername)
|
||||
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
||||
}
|
||||
|
||||
function getTokenHtml(): string {
|
||||
if (!cachedTokenTemplate) {
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||
}
|
||||
|
||||
return cachedTokenTemplate
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/login", async (_request, reply) => {
|
||||
const status = deps.authManager.getStatus()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
|
||||
app.get("/auth/token", async (request, reply) => {
|
||||
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.type("text/html").send(getTokenHtml())
|
||||
})
|
||||
|
||||
app.get("/api/auth/status", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.send({ authenticated: false })
|
||||
return
|
||||
}
|
||||
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
|
||||
})
|
||||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const body = LoginSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid credentials" })
|
||||
return
|
||||
}
|
||||
|
||||
const session = deps.authManager.createSession(body.username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/token", async (request, reply) => {
|
||||
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = TokenSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.consumeBootstrapToken(body.token)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid token" })
|
||||
return
|
||||
}
|
||||
|
||||
const username = deps.authManager.getStatus().username
|
||||
const session = deps.authManager.createSession(username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
deps.authManager.clearSessionCookie(reply)
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/password", async (request, reply) => {
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (!session) {
|
||||
reply.code(401).send({ error: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = PasswordSchema.parse(request.body ?? {})
|
||||
try {
|
||||
const status = deps.authManager.setPassword(body.password)
|
||||
reply.send({ ok: true, ...status })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
reply.code(409).type("text/plain").send(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&"
|
||||
case "<":
|
||||
return "<"
|
||||
case ">":
|
||||
return ">"
|
||||
case '"':
|
||||
return """
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,15 @@ export class InstanceEventBridge {
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers,
|
||||
signal,
|
||||
dispatcher: STREAM_AGENT,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
import {
|
||||
buildOpencodeBasicAuthHeader,
|
||||
DEFAULT_OPENCODE_USERNAME,
|
||||
generateOpencodeServerPassword,
|
||||
OPENCODE_SERVER_PASSWORD_ENV,
|
||||
OPENCODE_SERVER_USERNAME_ENV,
|
||||
} from "./opencode-auth"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
@@ -29,6 +36,7 @@ export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly opencodeConfigDir: string
|
||||
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
@@ -47,6 +55,10 @@ export class WorkspaceManager {
|
||||
return this.workspaces.get(id)?.port
|
||||
}
|
||||
|
||||
getInstanceAuthorizationHeader(id: string): string | undefined {
|
||||
return this.opencodeAuth.get(id)?.authorization
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
@@ -106,11 +118,22 @@ export class WorkspaceManager {
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
|
||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||
const opencodePassword = generateOpencodeServerPassword()
|
||||
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
||||
if (!authorization) {
|
||||
throw new Error("Failed to build OpenCode auth header")
|
||||
}
|
||||
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
||||
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -154,6 +177,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
this.opencodeAuth.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
@@ -163,17 +187,29 @@ export class WorkspaceManager {
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
|
||||
const stopTasks: Array<Promise<void>> = []
|
||||
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
if (!workspace.pid) {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
continue
|
||||
}
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
stopTasks.push(
|
||||
this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stopTasks.length > 0) {
|
||||
await Promise.allSettled(stopTasks)
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
}
|
||||
|
||||
@@ -200,13 +236,15 @@ export class WorkspaceManager {
|
||||
try {
|
||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const resolved = result.stdout
|
||||
const candidates = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
.filter((line) => line.length > 0)
|
||||
.filter((line) => !/^INFO:/i.test(line))
|
||||
|
||||
if (resolved) {
|
||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||
if (candidates.length > 0) {
|
||||
const resolved = this.pickBinaryCandidate(candidates)
|
||||
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
||||
return resolved
|
||||
}
|
||||
} else if (result.error) {
|
||||
@@ -219,6 +257,23 @@ export class WorkspaceManager {
|
||||
return identifier
|
||||
}
|
||||
|
||||
private pickBinaryCandidate(candidates: string[]): string {
|
||||
if (process.platform !== "win32") {
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
||||
|
||||
for (const ext of extensionPreference) {
|
||||
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
@@ -317,7 +372,13 @@ export class WorkspaceManager {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const headers: Record<string, string> = {}
|
||||
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
@@ -408,6 +469,8 @@ export class WorkspaceManager {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
this.opencodeAuth.delete(workspaceId)
|
||||
|
||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||
|
||||
workspace.pid = undefined
|
||||
|
||||
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
|
||||
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
|
||||
|
||||
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
|
||||
|
||||
export function generateOpencodeServerPassword(): string {
|
||||
return crypto.randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
|
||||
const username = params.username
|
||||
const password = params.password
|
||||
|
||||
if (!username || !password) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||
return `Basic ${token}`
|
||||
}
|
||||
@@ -1,10 +1,59 @@
|
||||
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"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true } as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {} as const,
|
||||
}
|
||||
}
|
||||
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
redacted[key] = value
|
||||
continue
|
||||
}
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
interface LaunchOptions {
|
||||
workspaceId: string
|
||||
folder: string
|
||||
@@ -59,22 +108,27 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
args,
|
||||
spawnCommand: spec.command,
|
||||
spawnArgs: spec.args,
|
||||
commandLine,
|
||||
env,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
@@ -207,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 = () => {
|
||||
@@ -222,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.6.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -166,6 +166,44 @@ function copyServerArtifacts() {
|
||||
}
|
||||
}
|
||||
|
||||
function stripNodeModuleBins() {
|
||||
const root = path.join(serverDest, "node_modules")
|
||||
if (!fs.existsSync(root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
fs.rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
@@ -192,4 +230,5 @@ ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -7,14 +7,15 @@ use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
@@ -31,9 +32,17 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
log_line(&format!("navigating main to {url}"));
|
||||
let mut display = url.to_string();
|
||||
if let Some(hash_index) = display.find('#') {
|
||||
display.replace_range(hash_index + 1.., "[REDACTED]");
|
||||
}
|
||||
log_line(&format!("navigating main to {display}"));
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
let _ = win.navigate(parsed);
|
||||
} else {
|
||||
@@ -44,6 +53,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
let prefix = format!("{name}=");
|
||||
let cookie_kv = set_cookie.split(';').next()?.trim();
|
||||
if !cookie_kv.starts_with(&prefix) {
|
||||
return None;
|
||||
}
|
||||
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
|
||||
// This is only used for local bootstrap; we assume plain HTTP.
|
||||
let mut stream = TcpStream::connect((host, port))?;
|
||||
|
||||
let body = format!("{{\"token\":\"{}\"}}", token);
|
||||
let request = format!(
|
||||
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.as_bytes().len(),
|
||||
body
|
||||
);
|
||||
|
||||
stream.write_all(request.as_bytes())?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response)?;
|
||||
|
||||
let (raw_headers, _rest) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.or_else(|| response.split_once("\n\n"))
|
||||
.unwrap_or((response.as_str(), ""));
|
||||
|
||||
let mut lines = raw_headers.lines();
|
||||
let status_line = lines.next().unwrap_or("");
|
||||
if !status_line.contains(" 200 ") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(tauri::webview::cookie::SameSite::Lax)
|
||||
.build();
|
||||
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
win.set_cookie(cookie)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -139,6 +227,7 @@ pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl CliProcessManager {
|
||||
@@ -147,6 +236,7 @@ impl CliProcessManager {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +244,7 @@ impl CliProcessManager {
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop()?;
|
||||
self.ready.store(false, Ordering::SeqCst);
|
||||
*self.bootstrap_token.lock() = None;
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
@@ -167,8 +258,9 @@ impl CliProcessManager {
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
@@ -186,6 +278,7 @@ impl CliProcessManager {
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
@@ -200,7 +293,12 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
CLI_STOP_GRACE_SECS,
|
||||
child.id()
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
@@ -237,6 +335,7 @@ impl CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
dev: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
@@ -318,8 +417,10 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
@@ -332,10 +433,10 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -407,10 +508,12 @@ impl CliProcessManager {
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
@@ -419,6 +522,17 @@ impl CliProcessManager {
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
if !line.is_empty() {
|
||||
if line.starts_with(token_prefix) {
|
||||
let token = line.trim_start_matches(token_prefix).trim();
|
||||
if !token.is_empty() {
|
||||
let mut guard = bootstrap_token.lock();
|
||||
if guard.is_none() {
|
||||
*guard = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log_line(&format!("[cli][{}] {}", stream, line));
|
||||
|
||||
if ready.load(Ordering::SeqCst) {
|
||||
@@ -430,7 +544,7 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -440,13 +554,13 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, port as u16);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -458,16 +572,46 @@ impl CliProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||
fn mark_ready(
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
port: u16,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let mut locked = status.lock();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
locked.port = Some(port);
|
||||
locked.url = Some(url.clone());
|
||||
locked.url = Some(base_url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
log_line(&format!("cli ready on {url}"));
|
||||
navigate_main(app, &url);
|
||||
log_line(&format!("cli ready on {base_url}"));
|
||||
|
||||
let token = bootstrap_token.lock().take();
|
||||
|
||||
if let Some(token) = token {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
let _ = app.emit("cli:ready", locked.clone());
|
||||
Self::emit_status(app, &locked);
|
||||
}
|
||||
@@ -551,6 +695,7 @@ impl CliEntry {
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
if dev {
|
||||
args.push("--ui-dev-server".to_string());
|
||||
|
||||
@@ -163,7 +163,8 @@ fn main() {
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||
api.prevent_exit();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -173,18 +174,18 @@ fn main() {
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
api.prevent_close();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.1",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -94,6 +95,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void initGithubStars()
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -99,15 +100,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
data-agent-selector
|
||||
class="selector-trigger"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label">
|
||||
<span class="selector-trigger-primary">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||
|
||||
38
packages/ui/src/components/brand-icons.tsx
Normal file
38
packages/ui/src/components/brand-icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
type BrandIconProps = {
|
||||
class?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 98 96"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 64 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -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
|
||||
|
||||
30
packages/ui/src/components/expand-button.tsx
Normal file
30
packages/ui/src/components/expand-button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Show } from "solid-js"
|
||||
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||
|
||||
interface ExpandButtonProps {
|
||||
expandState: () => "normal" | "expanded"
|
||||
onToggleExpand: (nextState: "normal" | "expanded") => void
|
||||
}
|
||||
|
||||
export default function ExpandButton(props: ExpandButtonProps) {
|
||||
function handleClick() {
|
||||
const current = props.expandState()
|
||||
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-expand-button"
|
||||
onClick={handleClick}
|
||||
aria-label="Toggle chat input height"
|
||||
>
|
||||
<Show
|
||||
when={props.expandState() === "normal"}
|
||||
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
<Maximize2 class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
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"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -56,6 +60,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 = [
|
||||
@@ -174,6 +191,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
@@ -228,167 +250,228 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</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>
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad GitHub"
|
||||
title="CodeNomad GitHub"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label="CodeNomad GitHub Stars"
|
||||
title="CodeNomad GitHub Stars"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
<Show when={githubStars() !== null}>
|
||||
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad Discord"
|
||||
title="CodeNomad Discord"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Left column: version + browse + advanced settings */}
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-body flex items-center justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,15 +48,16 @@ import { clearSessionRenderCache } from "../message-block"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import Kbd from "../kbd"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import ThinkingSelector from "../thinking-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import Kbd from "../kbd"
|
||||
import { TodoListView } from "../tool-call/renderers/todo"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const focusVariantSelectorControl = () => {
|
||||
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingSidebarAction()
|
||||
if (!pending) return
|
||||
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
setPendingSidebarAction(null)
|
||||
return
|
||||
}
|
||||
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
|
||||
const handled =
|
||||
action === "focus-agent-selector"
|
||||
? focusAgentSelectorControl()
|
||||
: action === "focus-model-selector"
|
||||
? focusModelSelectorControl()
|
||||
: focusVariantSelectorControl()
|
||||
if (handled) {
|
||||
setPendingSidebarAction(null)
|
||||
}
|
||||
@@ -875,7 +889,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={allInstanceSessions()}
|
||||
threads={sessionThreads()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
@@ -902,21 +915,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { FoldVertical } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
@@ -82,8 +82,20 @@ interface TaskSessionLocation {
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
|
||||
if (!sessionId) return null
|
||||
|
||||
if (preferredInstanceId) {
|
||||
const session = sessions().get(preferredInstanceId)?.get(sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
instanceId: preferredInstanceId,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allSessions = sessions()
|
||||
for (const [instanceId, sessionMap] of allSessions) {
|
||||
const session = sessionMap?.get(sessionId)
|
||||
@@ -235,16 +247,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const index = props.messageIndex
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||
const infoTimestamp =
|
||||
typeof infoTime.completed === "number"
|
||||
? infoTime.completed
|
||||
: typeof infoTime.updated === "number"
|
||||
? infoTime.updated
|
||||
: infoTime.created ?? 0
|
||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||
|
||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
||||
// a full message block rebuild; record revision is the invalidation key.
|
||||
const info = untrack(messageInfo)
|
||||
|
||||
const cacheSignature = [
|
||||
current.id,
|
||||
current.revision,
|
||||
@@ -252,8 +259,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
props.showThinking() ? 1 : 0,
|
||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||
props.showUsageMetrics() ? 1 : 0,
|
||||
infoTimestamp,
|
||||
infoErrorName,
|
||||
].join("|")
|
||||
|
||||
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||
@@ -447,7 +452,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const hasToolState =
|
||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -105,7 +106,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Model: {currentModelValue()?.name ?? "None"}
|
||||
</span>
|
||||
@@ -115,6 +116,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||
import { activePermissionId, getPermissionQueue } from "../stores/instances"
|
||||
import { loadMessages, setActiveSession } from "../stores/sessions"
|
||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||
import {
|
||||
activeInterruption,
|
||||
getPermissionQueue,
|
||||
getQuestionQueue,
|
||||
getQuestionEnqueuedAtForInstance,
|
||||
sendPermissionResponse,
|
||||
} from "../stores/instances"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
@@ -88,24 +95,111 @@ function resolveToolCallFromPermission(
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
const messageId = getQuestionMessageId(request)
|
||||
if (!sessionId || !messageId) return null
|
||||
|
||||
const store = messageStoreBus.getInstance(instanceId)
|
||||
if (!store) return null
|
||||
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) return null
|
||||
|
||||
const callId = getQuestionCallId(request)
|
||||
if (!callId) return null
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const partRecord = record.parts?.[partId]
|
||||
const part = partRecord?.data as any
|
||||
if (!part || part.type !== "tool") continue
|
||||
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
|
||||
if (partCallId !== callId) continue
|
||||
|
||||
if (typeof part.id !== "string" || part.id.length === 0) continue
|
||||
return {
|
||||
messageId,
|
||||
sessionId,
|
||||
toolPart: part as ResolvedToolCall["toolPart"],
|
||||
messageVersion: record.revision,
|
||||
partVersion: partRecord?.revision ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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 queue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
|
||||
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 orderedQueue = createMemo(() => {
|
||||
const current = queue()
|
||||
const activeId = activePermId()
|
||||
if (!activeId) return current
|
||||
const index = current.findIndex((entry) => entry.id === activeId)
|
||||
if (index <= 0) return current
|
||||
const active = current[index]
|
||||
if (!active) return current
|
||||
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
|
||||
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))
|
||||
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||
|
||||
type InterruptionItem =
|
||||
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
|
||||
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
|
||||
|
||||
const orderedQueue = createMemo<InterruptionItem[]>(() => {
|
||||
const permissions = permissionQueue().map((permission) => ({
|
||||
kind: "permission" as const,
|
||||
id: permission.id,
|
||||
sessionId: getPermissionSessionId(permission) || "",
|
||||
createdAt: (permission as any)?.time?.created ?? Date.now(),
|
||||
payload: permission,
|
||||
}))
|
||||
|
||||
const questions = questionQueue().map((question) => ({
|
||||
kind: "question" as const,
|
||||
id: question.id,
|
||||
sessionId: getQuestionSessionId(question) || "",
|
||||
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
|
||||
payload: question,
|
||||
}))
|
||||
|
||||
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
|
||||
})
|
||||
|
||||
const hasPermissions = createMemo(() => queue().length > 0)
|
||||
const hasRequests = createMemo(() => orderedQueue().length > 0)
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -122,7 +216,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.isOpen) return
|
||||
if (queue().length === 0) {
|
||||
if (orderedQueue().length === 0) {
|
||||
props.onClose()
|
||||
}
|
||||
})
|
||||
@@ -145,7 +239,14 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
|
||||
function handleGoToSession(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
setActiveSession(props.instanceId, sessionId)
|
||||
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
}
|
||||
|
||||
setActiveSessionFromList(props.instanceId, sessionId)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -156,10 +257,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<div class="permission-center-modal-header">
|
||||
<div class="permission-center-modal-title-row">
|
||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||
Permissions
|
||||
Requests
|
||||
</h2>
|
||||
<Show when={queue().length > 0}>
|
||||
<span class="permission-center-modal-count">{queue().length}</span>
|
||||
<Show when={orderedQueue().length > 0}>
|
||||
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
|
||||
@@ -168,16 +269,40 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
</div>
|
||||
|
||||
<div class="permission-center-modal-body">
|
||||
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}>
|
||||
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
|
||||
<div class="permission-center-list" role="list">
|
||||
<For each={orderedQueue()}>
|
||||
{(permission) => {
|
||||
const sessionId = getPermissionSessionId(permission) || ""
|
||||
const isActive = () => permission.id === activePermId()
|
||||
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
|
||||
{(item) => {
|
||||
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
|
||||
const sessionId = () => item.sessionId
|
||||
|
||||
const resolved = createMemo(() => {
|
||||
if (item.kind === "permission") {
|
||||
return resolveToolCallFromPermission(props.instanceId, item.payload)
|
||||
}
|
||||
return resolveToolCallFromQuestion(props.instanceId, item.payload)
|
||||
})
|
||||
|
||||
const showFallback = () => !resolved()
|
||||
|
||||
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
|
||||
|
||||
const primaryTitle = () => {
|
||||
if (item.kind === "permission") {
|
||||
return getPermissionDisplayTitle(item.payload)
|
||||
}
|
||||
const first = item.payload.questions?.[0]?.question
|
||||
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
|
||||
}
|
||||
|
||||
const secondaryTitle = () => {
|
||||
if (item.kind === "permission") {
|
||||
return getPermissionKind(item.payload)
|
||||
}
|
||||
const count = item.payload.questions?.length ?? 0
|
||||
return count === 1 ? "1 question" : `${count} questions`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
||||
@@ -185,7 +310,8 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
>
|
||||
<div class="permission-center-item-header">
|
||||
<div class="permission-center-item-heading">
|
||||
<span class="permission-center-item-kind">{getPermissionKind(permission)}</span>
|
||||
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
|
||||
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
||||
<Show when={isActive()}>
|
||||
<span class="permission-center-item-chip">Active</span>
|
||||
</Show>
|
||||
@@ -195,7 +321,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-item-action"
|
||||
onClick={() => handleGoToSession(sessionId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleGoToSession(sessionId())
|
||||
}}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
@@ -203,26 +332,64 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-item-action"
|
||||
disabled={loadingSession() === sessionId}
|
||||
onClick={() => handleLoadSession(sessionId)}
|
||||
disabled={loadingSession() === sessionId()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLoadSession(sessionId())
|
||||
}}
|
||||
>
|
||||
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
|
||||
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={resolved()}
|
||||
fallback={
|
||||
<div class="permission-center-fallback">
|
||||
<div class="permission-center-fallback-title">
|
||||
<code>{getPermissionDisplayTitle(permission)}</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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show, createMemo, type Component } from "solid-js"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
import { getPermissionQueueLength } from "../stores/instances"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||
|
||||
interface PermissionNotificationBannerProps {
|
||||
instanceId: string
|
||||
@@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps {
|
||||
}
|
||||
|
||||
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||
const hasPermissions = createMemo(() => queueLength() > 0)
|
||||
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||
const hasRequests = createMemo(() => queueLength() > 0)
|
||||
const label = createMemo(() => {
|
||||
const count = queueLength()
|
||||
return `${count} permission${count === 1 ? "" : "s"} pending approval`
|
||||
const total = queueLength()
|
||||
const parts: string[] = []
|
||||
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
|
||||
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
|
||||
const detail = parts.length ? ` (${parts.join(", ")})` : ""
|
||||
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={hasPermissions()}>
|
||||
<Show when={hasRequests()}>
|
||||
<button
|
||||
type="button"
|
||||
class="permission-center-trigger"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { addToHistory, getHistory } from "../stores/message-history"
|
||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
@@ -46,9 +47,16 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
|
||||
const SELECTION_INSERT_MAX_LENGTH = 2000
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (mode() === "shell") {
|
||||
return "Run a shell command (Esc to exit)..."
|
||||
}
|
||||
return "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -596,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
}
|
||||
|
||||
setExpandState("normal")
|
||||
clearPrompt()
|
||||
|
||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||
@@ -615,7 +624,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
// Record attempted slash commands even if execution fails.
|
||||
void refreshHistory()
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (isShellMode) {
|
||||
if (props.onRunShell) {
|
||||
@@ -642,7 +651,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function focusTextareaEnd() {
|
||||
if (!textareaRef) return
|
||||
setTimeout(() => {
|
||||
@@ -652,7 +661,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
function canUseHistory(force = false) {
|
||||
if (force) return true
|
||||
if (showPicker()) return false
|
||||
@@ -660,29 +669,29 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (!textarea) return false
|
||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
}
|
||||
|
||||
|
||||
function selectPreviousHistory(force = false) {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(force)) return false
|
||||
|
||||
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
|
||||
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
focusTextareaEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
function selectNextHistory(force = false) {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(force)) return false
|
||||
if (historyIndex() === -1) return false
|
||||
|
||||
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
@@ -696,12 +705,18 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
focusTextareaEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
function handleAbort() {
|
||||
if (!props.onAbortSession || !props.isSessionBusy) return
|
||||
void props.onAbortSession()
|
||||
}
|
||||
|
||||
|
||||
function handleExpandToggle(nextState: "normal" | "expanded") {
|
||||
setExpandState(nextState)
|
||||
// Keep focus on textarea
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
@@ -765,9 +780,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
item:
|
||||
| { type: "agent"; agent: Agent }
|
||||
| {
|
||||
type: "file"
|
||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||
}
|
||||
type: "file"
|
||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||
}
|
||||
| { type: "command"; command: SDKCommand },
|
||||
) {
|
||||
if (item.type === "command") {
|
||||
@@ -829,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)
|
||||
@@ -873,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)
|
||||
|
||||
@@ -1018,18 +1036,18 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
|
||||
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
||||
|
||||
|
||||
const hasHistory = () => history().length > 0
|
||||
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
||||
const canHistoryGoNext = () => historyIndex() >= 0
|
||||
|
||||
|
||||
const canSend = () => {
|
||||
if (props.disabled) return false
|
||||
const hasText = prompt().trim().length > 0
|
||||
if (mode() === "shell") return hasText
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
||||
const commandHint = () => ({ key: "/", text: "Commands" })
|
||||
|
||||
@@ -1040,7 +1058,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
||||
style={
|
||||
isDragging()
|
||||
@@ -1067,188 +1084,92 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isImage = attachment.mediaType.startsWith("image/")
|
||||
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
|
||||
const isTextAttachment = typeof textValue === "string"
|
||||
return (
|
||||
<div
|
||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
||||
title={textValue}
|
||||
>
|
||||
<Show
|
||||
when={isImage}
|
||||
fallback={
|
||||
<Show
|
||||
when={isTextAttachment}
|
||||
fallback={
|
||||
<Show
|
||||
when={attachment.source.type === "agent"}
|
||||
fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
|
||||
<Show when={isTextAttachment}>
|
||||
<button
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
class="attachment-expand"
|
||||
aria-label="Expand pasted text"
|
||||
title="Insert pasted text"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
class="attachment-remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={attachment.filename} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="prompt-input-field-container">
|
||||
<div class="prompt-input-field">
|
||||
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||
|
||||
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
mode() === "shell"
|
||||
? "Run a shell command (Esc to exit)..."
|
||||
: "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={4}
|
||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<div class="prompt-history-top">
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectPreviousHistory(true)}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label="Previous prompt"
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||
placeholder={getPlaceholder()}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={expandState() === "expanded" ? 15 : 4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectPreviousHistory(true)}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label="Previous prompt"
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectNextHistory(true)}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label="Next prompt"
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-history-bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectNextHistory(true)}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label="Next prompt"
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
<>
|
||||
<span class="prompt-overlay-text">
|
||||
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
||||
</span>
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||
</Show>
|
||||
<span class="prompt-overlay-text">
|
||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||
</span>
|
||||
<Show when={mode() !== "shell"}>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
<>
|
||||
<span class="prompt-overlay-text">
|
||||
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
||||
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||
</Show>
|
||||
<span class="prompt-overlay-text">
|
||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||
</span>
|
||||
<Show when={mode() !== "shell"}>
|
||||
<span class="prompt-overlay-text">
|
||||
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={mode() === "shell"}>
|
||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||
Press <Kbd>Esc</Kbd> again to abort session
|
||||
</span>
|
||||
<Show when={mode() === "shell"}>
|
||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||
Press <Kbd>Esc</Kbd> again to abort session
|
||||
</span>
|
||||
<Show when={mode() === "shell"}>
|
||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||
</Show>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-actions">
|
||||
<button
|
||||
|
||||
@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
|
||||
|
||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||
const [passwordValue, setPasswordValue] = createSignal("")
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const refreshMeta = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setPasswordError(null)
|
||||
try {
|
||||
const result = await serverApi.fetchServerMeta()
|
||||
setMeta(result)
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPassword = async () => {
|
||||
setPasswordError(null)
|
||||
|
||||
const next = passwordValue()
|
||||
const confirm = passwordConfirm()
|
||||
|
||||
if (next.trim().length < 8) {
|
||||
setPasswordError("Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
|
||||
if (next !== confirm) {
|
||||
setPasswordError("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
const result = await serverApi.setServerPassword(next)
|
||||
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
|
||||
setPasswordValue("")
|
||||
setPasswordConfirm("")
|
||||
setPasswordFormOpen(false)
|
||||
} catch (err) {
|
||||
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Server password</p>
|
||||
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={authStatus() && authStatus()!.authenticated}
|
||||
fallback={<div class="remote-card">Authentication status unavailable.</div>}
|
||||
>
|
||||
<div class="remote-card">
|
||||
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
|
||||
<p class="remote-help">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? "A password is set for remote access."
|
||||
: "No memorable password is set yet. Set one to allow remote handover logins."}
|
||||
</p>
|
||||
|
||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? "Cancel"
|
||||
: authStatus()!.passwordUserProvided
|
||||
? "Change password"
|
||||
: "Set password"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
||||
<label class="text-sm font-medium text-secondary">New password</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordValue()}
|
||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
||||
<label class="text-sm font-medium text-secondary">Confirm password</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordConfirm()}
|
||||
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={passwordError()}>
|
||||
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
disabled={savingPassword()}
|
||||
onClick={() => void handleSubmitPassword()}
|
||||
>
|
||||
{savingPassword() ? "Saving…" : "Save password"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Wifi class="remote-icon" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isSessionParentExpanded,
|
||||
loading,
|
||||
renameSession,
|
||||
sessions as sessionStateSessions,
|
||||
setActiveSessionFromList,
|
||||
toggleSessionParentExpanded,
|
||||
} from "../stores/sessions"
|
||||
@@ -25,7 +26,6 @@ const log = getLogger("session")
|
||||
|
||||
interface SessionListProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
threads: SessionThread[]
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
@@ -58,7 +58,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = props.sessions.get(sessionId)
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
@@ -132,7 +132,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}
|
||||
|
||||
const openRenameDialog = (sessionId: string) => {
|
||||
const session = props.sessions.get(sessionId)
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
if (!session) return
|
||||
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||
@@ -167,7 +167,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
expanded?: boolean
|
||||
onToggleExpand?: () => void
|
||||
}> = (rowProps) => {
|
||||
const session = () => props.sessions.get(rowProps.sessionId)
|
||||
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
|
||||
if (!session()) {
|
||||
return <></>
|
||||
}
|
||||
@@ -175,9 +175,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const title = () => session()?.title || "Untitled"
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => formatSessionStatus(status())
|
||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
@@ -224,7 +226,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{pendingPermission() ? (
|
||||
{needsInput() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
@@ -291,7 +293,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return null
|
||||
|
||||
const activeSession = props.sessions.get(activeId)
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return null
|
||||
|
||||
return activeSession.parentId ?? activeSession.id
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
||||
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
@@ -39,6 +42,62 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
if (!currentSession) return false
|
||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||
})
|
||||
|
||||
const sessionNeedsInput = createMemo(() => {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return false
|
||||
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
|
||||
})
|
||||
|
||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||
|
||||
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(/pasted #(\d+)/)
|
||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||
|
||||
const currentText = textarea?.value ?? ""
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = nextText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
function scheduleScrollToBottom() {
|
||||
@@ -224,17 +283,52 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerQuoteHandler={registerQuoteHandler}
|
||||
/>
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
aria-label="Expand pasted text"
|
||||
title="Insert pasted text"
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerQuoteHandler={registerQuoteHandler}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
107
packages/ui/src/components/thinking-selector.tsx
Normal file
107
packages/ui/src/components/thinking-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ThinkingSelectorProps {
|
||||
instanceId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
}
|
||||
|
||||
type ThinkingOption = {
|
||||
key: string
|
||||
label: string
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
const variantKeys = createMemo(() => {
|
||||
const { providerId, modelId } = props.currentModel
|
||||
const provider = instanceProviders().find((p) => p.id === providerId)
|
||||
const model = provider?.models.find((m) => m.id === modelId)
|
||||
return model?.variantKeys ?? []
|
||||
})
|
||||
|
||||
const options = createMemo<ThinkingOption[]>(() => {
|
||||
const keys = variantKeys()
|
||||
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))]
|
||||
})
|
||||
|
||||
const currentValue = createMemo(() => {
|
||||
const selected = getModelThinkingSelection(props.currentModel)
|
||||
const keys = variantKeys()
|
||||
if (selected && keys.includes(selected)) {
|
||||
return options().find((opt) => opt.value === selected)
|
||||
}
|
||||
return options()[0]
|
||||
})
|
||||
|
||||
const handleChange = (value: ThinkingOption | null) => {
|
||||
if (!value) return
|
||||
setModelThinkingSelection(props.currentModel, value.value)
|
||||
}
|
||||
|
||||
const triggerPrimary = createMemo(() => {
|
||||
const selected = currentValue()?.value
|
||||
return selected ? `Thinking: ${selected}` : "Thinking: Default"
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<ThinkingOption>
|
||||
value={currentValue()}
|
||||
onChange={handleChange}
|
||||
options={options()}
|
||||
optionValue="key"
|
||||
optionLabel="label"
|
||||
placeholder="Thinking: Default"
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item item={itemProps.item} class="selector-option">
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-thinking-selector-control>
|
||||
<Combobox.Input class="sr-only" data-thinking-selector />
|
||||
<Combobox.Trigger class="selector-trigger">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
|
||||
</div>
|
||||
<span class="selector-trigger-hint" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +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 { sendPermissionResponse } from "../stores/instances"
|
||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
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,
|
||||
@@ -22,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
|
||||
@@ -61,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()
|
||||
@@ -239,6 +85,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}))
|
||||
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||
|
||||
const cacheVersion = createMemo(() => {
|
||||
if (typeof props.partVersion === "number") {
|
||||
@@ -250,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,
|
||||
@@ -267,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(() => {
|
||||
@@ -278,6 +126,16 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
return toolCallMemo()?.pendingPermission
|
||||
})
|
||||
|
||||
const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier()))
|
||||
const pendingQuestion = createMemo(() => {
|
||||
const state = questionState()
|
||||
if (state) {
|
||||
return { request: state.entry.request as QuestionRequest, active: state.active }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
|
||||
@@ -292,27 +150,45 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
||||
|
||||
const isPermissionActive = createMemo(() => {
|
||||
const pending = pendingPermission()
|
||||
if (!pending?.permission) return false
|
||||
const active = activeRequest()
|
||||
return active?.kind === "permission" && active.id === pending.permission.id
|
||||
})
|
||||
|
||||
const isQuestionActive = createMemo(() => {
|
||||
const pending = pendingQuestion()
|
||||
if (!pending?.request) return false
|
||||
const active = activeRequest()
|
||||
return active?.kind === "question" && active.id === pending.request.id
|
||||
})
|
||||
|
||||
const expanded = () => {
|
||||
const permission = pendingPermission()
|
||||
if (permission?.active) return true
|
||||
if (isPermissionActive() || isQuestionActive()) return true
|
||||
const override = userExpanded()
|
||||
if (override !== null) return override
|
||||
return defaultExpandedForTool()
|
||||
}
|
||||
|
||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||
const questionDetails = createMemo(() => pendingQuestion()?.request)
|
||||
|
||||
const activePermissionKey = createMemo(() => {
|
||||
const permission = permissionDetails()
|
||||
return permission && isPermissionActive() ? permission.id : ""
|
||||
})
|
||||
|
||||
const activeQuestionKey = createMemo(() => {
|
||||
const request = questionDetails()
|
||||
return request && isQuestionActive() ? request.id : ""
|
||||
})
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
||||
|
||||
const diagnosticsExpanded = () => {
|
||||
const permission = pendingPermission()
|
||||
if (permission?.active) return true
|
||||
if (isPermissionActive() || isQuestionActive()) return true
|
||||
const override = diagnosticsOverride()
|
||||
if (override !== undefined) return override
|
||||
return diagnosticsDefaultExpanded()
|
||||
@@ -513,7 +389,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
const activeKey = activePermissionKey() || activeQuestionKey()
|
||||
if (!activeKey) return
|
||||
requestAnimationFrame(() => {
|
||||
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
@@ -524,15 +400,94 @@ 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)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||
})
|
||||
|
||||
const [questionSubmitting, setQuestionSubmitting] = createSignal(false)
|
||||
const [questionError, setQuestionError] = createSignal<string | null>(null)
|
||||
|
||||
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
|
||||
|
||||
function isTextInputFocused() {
|
||||
const active = document.activeElement
|
||||
return (
|
||||
active?.tagName === "TEXTAREA" ||
|
||||
active?.tagName === "INPUT" ||
|
||||
(active?.hasAttribute("contenteditable") ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleQuestionSubmit() {
|
||||
const request = questionDetails()
|
||||
if (!request || !isQuestionActive()) {
|
||||
return
|
||||
}
|
||||
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
|
||||
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
|
||||
}
|
||||
|
||||
setQuestionSubmitting(true)
|
||||
setQuestionError(null)
|
||||
try {
|
||||
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
||||
} catch (error) {
|
||||
log.error("Failed to send question reply", error)
|
||||
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
|
||||
} finally {
|
||||
setQuestionSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuestionDismiss() {
|
||||
const request = questionDetails()
|
||||
if (!request || !isQuestionActive()) {
|
||||
return
|
||||
}
|
||||
setQuestionSubmitting(true)
|
||||
setQuestionError(null)
|
||||
try {
|
||||
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
||||
} catch (error) {
|
||||
log.error("Failed to reject question", error)
|
||||
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
|
||||
} finally {
|
||||
setQuestionSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activeQuestionKey()
|
||||
if (!activeKey) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isTextInputFocused()) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handleQuestionSubmit()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
void handleQuestionDismiss()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler)
|
||||
@@ -563,7 +518,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const combinedStatusClass = () => {
|
||||
const base = statusClass()
|
||||
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
||||
return pendingPermission() || pendingQuestion() ? `${base} tool-call-awaiting-permission` : base
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -579,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,
|
||||
@@ -831,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 {
|
||||
@@ -863,92 +659,50 @@ 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 = () => (
|
||||
<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()
|
||||
if (!request) {
|
||||
setQuestionSubmitting(false)
|
||||
setQuestionError(null)
|
||||
return
|
||||
}
|
||||
setQuestionError(null)
|
||||
const requestId = request.id
|
||||
setQuestionDraftAnswers((prev) => {
|
||||
if (prev[requestId]) return prev
|
||||
const initial = request.questions.map(() => [])
|
||||
return { ...prev, [requestId]: initial }
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const status = () => toolState()?.status || ""
|
||||
|
||||
@@ -993,6 +747,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
{renderError()}
|
||||
|
||||
{renderPermissionBlock()}
|
||||
{renderQuestionBlock()}
|
||||
|
||||
<Show when={status() === "pending" && !pendingPermission()}>
|
||||
<div class="tool-call-pending-message">
|
||||
|
||||
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"
|
||||
@@ -9,16 +10,19 @@ import { todoRenderer } from "./todo"
|
||||
import { webfetchRenderer } from "./webfetch"
|
||||
import { writeRenderer } from "./write"
|
||||
import { invalidRenderer } from "./invalid"
|
||||
import { questionRenderer } from "./question"
|
||||
|
||||
const TOOL_RENDERERS: ToolRenderer[] = [
|
||||
bashRenderer,
|
||||
readRenderer,
|
||||
writeRenderer,
|
||||
editRenderer,
|
||||
applyPatchRenderer,
|
||||
patchRenderer,
|
||||
webfetchRenderer,
|
||||
todoRenderer,
|
||||
taskRenderer,
|
||||
questionRenderer,
|
||||
invalidRenderer,
|
||||
]
|
||||
|
||||
|
||||
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
|
||||
export const questionRenderer: ToolRenderer = {
|
||||
tools: ["question"],
|
||||
getAction: () => "Awaiting answers...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return "Questions"
|
||||
if (state.status === "completed") return "Questions"
|
||||
return "Asking questions"
|
||||
},
|
||||
renderBody() {
|
||||
// The question tool UI is rendered by ToolCall itself so
|
||||
// it can share the same layout for pending/completed.
|
||||
return null
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,10 +45,14 @@ export function getToolIcon(tool: string): string {
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "📋"
|
||||
case "question":
|
||||
return "❓"
|
||||
case "list":
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
case "apply_patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
@@ -65,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)
|
||||
@@ -218,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,
|
||||
@@ -103,7 +104,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
logHttp(`${method} ${path}`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers })
|
||||
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
@@ -135,6 +136,15 @@ export const serverApi = {
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
|
||||
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
},
|
||||
deleteWorkspace(id: string): Promise<void> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
@@ -215,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)}`)
|
||||
},
|
||||
@@ -270,7 +287,7 @@ export const serverApi = {
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
|
||||
@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCompactCount(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
() => {
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
)
|
||||
|
||||
registerEscapeShortcut(
|
||||
|
||||
@@ -374,6 +374,20 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-variant-selector",
|
||||
label: "Select Model Variant",
|
||||
description: "Choose a thinking effort for the current model",
|
||||
category: "Agent & Model",
|
||||
keywords: ["variant", "thinking", "reasoning", "effort"],
|
||||
shortcut: { key: "T", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||
export type SessionSidebarRequestAction =
|
||||
| "focus-agent-selector"
|
||||
| "focus-model-selector"
|
||||
| "focus-variant-selector"
|
||||
| "show-session-list"
|
||||
|
||||
export interface SessionSidebarRequestDetail {
|
||||
instanceId: string
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
|
||||
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
|
||||
export function registerAgentShortcuts(
|
||||
focusModelSelector: () => void,
|
||||
openAgentSelector: () => void,
|
||||
focusVariantSelector: () => void,
|
||||
) {
|
||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
||||
|
||||
keyboardRegistry.register({
|
||||
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
|
||||
description: "open agent",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-variant",
|
||||
key: "T",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
|
||||
handler: focusVariantSelector,
|
||||
description: "focus thinking",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ type SSEEvent =
|
||||
| EventSessionIdle
|
||||
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
||||
| { type: "permission.replied"; properties?: any }
|
||||
| { type: "question.asked"; properties?: any }
|
||||
| { type: "question.replied" | "question.rejected"; properties?: any }
|
||||
| EventLspUpdated
|
||||
| TuiToastEvent
|
||||
| BackgroundProcessUpdatedEvent
|
||||
@@ -144,6 +146,13 @@ class SSEManager {
|
||||
case "permission.replied":
|
||||
this.onPermissionReplied?.(instanceId, event as any)
|
||||
break
|
||||
case "question.asked":
|
||||
this.onQuestionAsked?.(instanceId, event as any)
|
||||
break
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
this.onQuestionAnswered?.(instanceId, event as any)
|
||||
break
|
||||
case "lsp.updated":
|
||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||
break
|
||||
@@ -178,6 +187,8 @@ class SSEManager {
|
||||
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
||||
onPermissionUpdated?: (instanceId: string, event: any) => void
|
||||
onPermissionReplied?: (instanceId: string, event: any) => void
|
||||
onQuestionAsked?: (instanceId: string, event: any) => void
|
||||
onQuestionAnswered?: (instanceId: string, event: any) => void
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||
|
||||
54
packages/ui/src/stores/github-stars.ts
Normal file
54
packages/ui/src/stores/github-stars.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const STORAGE_KEY = "codenomad:github:stars"
|
||||
const REPO_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad"
|
||||
|
||||
function readStoredStars(): number | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value < 0) return null
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function storeStars(value: number): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(value))
|
||||
}
|
||||
|
||||
const [githubStars, setGithubStars] = createSignal<number | null>(readStoredStars())
|
||||
|
||||
let initialized = false
|
||||
|
||||
export async function initGithubStars(): Promise<void> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
const response = await fetch(REPO_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { stargazers_count?: unknown }
|
||||
const next = typeof data.stargazers_count === "number" ? data.stargazers_count : null
|
||||
if (next === null || !Number.isFinite(next) || next < 0) {
|
||||
return
|
||||
}
|
||||
const normalized = Math.floor(next)
|
||||
setGithubStars(normalized)
|
||||
storeStars(normalized)
|
||||
} catch (error) {
|
||||
log.warn("Failed to fetch GitHub stars", error)
|
||||
}
|
||||
}
|
||||
|
||||
export { githubStars }
|
||||
@@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { getQuestionSessionId } from "../types/question"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
@@ -18,10 +20,10 @@ import {
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission } from "./session-state"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
|
||||
import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
@@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
// Permission queue management per instance
|
||||
// Interruption queues (permissions + questions) per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
|
||||
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const questionSessionCounts = new Map<string, Map<string, number>>()
|
||||
const questionEnqueuedAt = new Map<string, number>()
|
||||
|
||||
function ensureQuestionEnqueuedAt(request: QuestionRequest): number {
|
||||
const existing = questionEnqueuedAt.get(request.id)
|
||||
if (existing) return existing
|
||||
const now = Date.now()
|
||||
questionEnqueuedAt.set(request.id, now)
|
||||
return now
|
||||
}
|
||||
|
||||
type InterruptionKind = "permission" | "question"
|
||||
|
||||
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
|
||||
|
||||
const [activeInterruption, setActiveInterruption] = createSignal<Map<string, ActiveInterruption>>(new Map())
|
||||
|
||||
function syncHasInstancesFlag() {
|
||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||
setHasInstances(readyExists)
|
||||
@@ -71,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)) {
|
||||
@@ -81,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +193,38 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
|
||||
try {
|
||||
const remote = await requestData<QuestionRequest[]>(
|
||||
instance.client.question.list(),
|
||||
"question.list",
|
||||
)
|
||||
|
||||
const remoteIds = new Set(remote.map((item) => item.id))
|
||||
const local = getQuestionQueue(instanceId)
|
||||
|
||||
// Remove any stale local requests missing from server.
|
||||
for (const entry of local) {
|
||||
if (!remoteIds.has(entry.id)) {
|
||||
removeQuestionFromQueue(instanceId, entry.id)
|
||||
removeQuestionV2(instanceId, entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert all server-side pending questions.
|
||||
for (const request of remote) {
|
||||
ensureQuestionEnqueuedAt(request)
|
||||
addQuestionToQueue(instanceId, request)
|
||||
upsertQuestionV2(instanceId, request)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to sync pending questions", { instanceId, error })
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
@@ -166,20 +235,24 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
await syncPendingPermissions(instanceId)
|
||||
await syncPendingQuestions(instanceId)
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch initial data", error)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -327,6 +400,7 @@ function removeInstance(id: string) {
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
clearPermissionQueue(id)
|
||||
clearQuestionQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
@@ -429,6 +503,79 @@ function getPermissionQueueLength(instanceId: string): number {
|
||||
return getPermissionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function getQuestionQueue(instanceId: string): QuestionRequest[] {
|
||||
const queue = questionQueues().get(instanceId)
|
||||
if (!queue) {
|
||||
return []
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
function getQuestionQueueLength(instanceId: string): number {
|
||||
return getQuestionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number {
|
||||
// Ensure we have a stable timestamp for sorting/ordering.
|
||||
const queue = getQuestionQueue(instanceId)
|
||||
const match = queue.find((q) => q.id === requestId)
|
||||
if (match) {
|
||||
return ensureQuestionEnqueuedAt(match)
|
||||
}
|
||||
return questionEnqueuedAt.get(requestId) ?? Date.now()
|
||||
}
|
||||
|
||||
function computeActiveInterruption(instanceId: string): ActiveInterruption {
|
||||
const permissions = getPermissionQueue(instanceId)
|
||||
const questions = getQuestionQueue(instanceId)
|
||||
const firstPermission = permissions[0]
|
||||
const firstQuestion = questions[0]
|
||||
if (!firstPermission && !firstQuestion) return null
|
||||
if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id }
|
||||
if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id }
|
||||
|
||||
const permTime = getPermissionCreatedAt(firstPermission)
|
||||
const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER
|
||||
if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id }
|
||||
return { kind: "question", id: firstQuestion!.id }
|
||||
}
|
||||
|
||||
function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void {
|
||||
setActiveInterruption((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!nextActive) {
|
||||
next.set(instanceId, null)
|
||||
} else {
|
||||
next.set(instanceId, nextActive)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (nextActive?.kind === "permission") {
|
||||
next.set(instanceId, nextActive.id)
|
||||
} else {
|
||||
next.set(instanceId, null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveQuestionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (nextActive?.kind === "question") {
|
||||
next.set(instanceId, nextActive.id)
|
||||
} else {
|
||||
next.set(instanceId, null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function recomputeActiveInterruption(instanceId: string): void {
|
||||
setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId))
|
||||
}
|
||||
|
||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) {
|
||||
@@ -464,6 +611,41 @@ function clearSessionPendingCounts(instanceId: string): void {
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||
let sessionCounts = questionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) {
|
||||
sessionCounts = new Map()
|
||||
questionSessionCounts.set(instanceId, sessionCounts)
|
||||
}
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
sessionCounts.set(sessionId, current + 1)
|
||||
}
|
||||
|
||||
function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return 0
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
if (current <= 1) {
|
||||
sessionCounts.delete(sessionId)
|
||||
if (sessionCounts.size === 0) {
|
||||
questionSessionCounts.delete(instanceId)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
const nextValue = current - 1
|
||||
sessionCounts.set(sessionId, nextValue)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function clearQuestionSessionPendingCounts(instanceId: string): void {
|
||||
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return
|
||||
for (const sessionId of sessionCounts.keys()) {
|
||||
setSessionPendingQuestion(instanceId, sessionId, false)
|
||||
}
|
||||
questionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
||||
let inserted = false
|
||||
|
||||
@@ -485,13 +667,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
||||
return
|
||||
}
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!next.get(instanceId)) {
|
||||
next.set(instanceId, permission.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
recomputeActiveInterruption(instanceId)
|
||||
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (sessionId) {
|
||||
@@ -526,15 +702,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
|
||||
const updatedQueue = getPermissionQueue(instanceId)
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
const activeId = next.get(instanceId)
|
||||
if (activeId === permissionId) {
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
recomputeActiveInterruption(instanceId)
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
@@ -558,16 +726,140 @@ function clearPermissionQueue(instanceId: string): void {
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
}
|
||||
|
||||
function addQuestionToQueue(instanceId: string, request: QuestionRequest): void {
|
||||
let inserted = false
|
||||
|
||||
|
||||
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
||||
setActivePermissionId((prev) => {
|
||||
setQuestionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, permissionId)
|
||||
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||
|
||||
if (queue.some((q) => q.id === request.id)) {
|
||||
return next
|
||||
}
|
||||
|
||||
ensureQuestionEnqueuedAt(request)
|
||||
const updatedQueue = [...queue, request].sort((a, b) => {
|
||||
return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b)
|
||||
})
|
||||
next.set(instanceId, updatedQueue)
|
||||
inserted = true
|
||||
return next
|
||||
})
|
||||
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
recomputeActiveInterruption(instanceId)
|
||||
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
if (sessionId) {
|
||||
incrementQuestionSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingQuestion(instanceId, sessionId, true)
|
||||
}
|
||||
}
|
||||
|
||||
function removeQuestionFromQueue(instanceId: string, requestId: string): void {
|
||||
const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId))
|
||||
|
||||
setQuestionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||
const filtered = queue.filter((item) => item.id !== requestId)
|
||||
|
||||
if (filtered.length > 0) {
|
||||
next.set(instanceId, filtered)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
questionEnqueuedAt.delete(requestId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
|
||||
if (removedSessionId) {
|
||||
const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
}
|
||||
|
||||
function clearQuestionQueue(instanceId: string): void {
|
||||
for (const request of getQuestionQueue(instanceId)) {
|
||||
questionEnqueuedAt.delete(request.id)
|
||||
}
|
||||
|
||||
setQuestionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
setActiveQuestionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
clearQuestionSessionPendingCounts(instanceId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
}
|
||||
|
||||
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
||||
setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId })
|
||||
}
|
||||
|
||||
function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void {
|
||||
setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId })
|
||||
}
|
||||
|
||||
async function sendQuestionReply(
|
||||
instanceId: string,
|
||||
_sessionId: string,
|
||||
requestId: string,
|
||||
answers: string[][],
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await requestData(
|
||||
instance.client.question.reply({
|
||||
requestID: requestId,
|
||||
answers,
|
||||
}),
|
||||
"question.reply",
|
||||
)
|
||||
|
||||
removeQuestionFromQueue(instanceId, requestId)
|
||||
} catch (error) {
|
||||
log.error("Failed to send question reply", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await requestData(
|
||||
instance.client.question.reject({
|
||||
requestID: requestId,
|
||||
}),
|
||||
"question.reject",
|
||||
)
|
||||
|
||||
removeQuestionFromQueue(instanceId, requestId)
|
||||
} catch (error) {
|
||||
log.error("Failed to send question reject", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
@@ -655,7 +947,7 @@ export {
|
||||
getInstanceLogs,
|
||||
isInstanceLogStreaming,
|
||||
setInstanceLogStreaming,
|
||||
// Permission management
|
||||
// Permission + question management
|
||||
permissionQueues,
|
||||
activePermissionId,
|
||||
getPermissionQueue,
|
||||
@@ -665,6 +957,18 @@ export {
|
||||
clearPermissionQueue,
|
||||
sendPermissionResponse,
|
||||
setActivePermissionIdForInstance,
|
||||
questionQueues,
|
||||
activeQuestionId,
|
||||
activeInterruption,
|
||||
getQuestionQueue,
|
||||
getQuestionQueueLength,
|
||||
getQuestionEnqueuedAtForInstance,
|
||||
addQuestionToQueue,
|
||||
removeQuestionFromQueue,
|
||||
clearQuestionQueue,
|
||||
sendQuestionReply,
|
||||
sendQuestionReject,
|
||||
setActiveQuestionIdForInstance,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
||||
import type { QuestionRequest } from "../../types/question"
|
||||
import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
|
||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||
import type { Session } from "../../types/session"
|
||||
import { messageStoreBus } from "./bus"
|
||||
@@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st
|
||||
}
|
||||
}
|
||||
|
||||
function extractQuestionMessageId(request: QuestionRequest): string | undefined {
|
||||
return getQuestionMessageId(request)
|
||||
}
|
||||
|
||||
function extractQuestionCallId(request: QuestionRequest): string | undefined {
|
||||
return getQuestionCallId(request)
|
||||
}
|
||||
|
||||
export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void {
|
||||
if (!request) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageId = extractQuestionMessageId(request)
|
||||
let partId: string | undefined = undefined
|
||||
const callId = extractQuestionCallId(request)
|
||||
if (callId) {
|
||||
partId = resolvePartIdFromCallId(store, messageId, callId)
|
||||
}
|
||||
store.upsertQuestion({
|
||||
request,
|
||||
messageId,
|
||||
partId,
|
||||
enqueuedAt: (request as any).time?.created ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const pending = store.state.questions.queue
|
||||
if (!pending || pending.length === 0) return
|
||||
|
||||
for (const entry of pending) {
|
||||
if (!entry || entry.partId) continue
|
||||
const request = entry.request
|
||||
if (!request) continue
|
||||
|
||||
const questionSessionId = request.sessionID
|
||||
if (sessionId && questionSessionId && questionSessionId !== sessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messageId = entry.messageId ?? extractQuestionMessageId(request)
|
||||
const callId = extractQuestionCallId(request)
|
||||
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
|
||||
if (!resolvedPartId) continue
|
||||
|
||||
store.upsertQuestion({
|
||||
...entry,
|
||||
messageId,
|
||||
partId: resolvedPartId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function removeQuestionV2(instanceId: string, requestId: string): void {
|
||||
if (!requestId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.removeQuestion(requestId)
|
||||
}
|
||||
|
||||
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||
if (!permissionId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PartUpdateInput,
|
||||
PendingPartEntry,
|
||||
PermissionEntry,
|
||||
QuestionEntry,
|
||||
ReplaceMessageIdOptions,
|
||||
ScrollSnapshot,
|
||||
SessionRecord,
|
||||
@@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
||||
active: null,
|
||||
byMessage: {},
|
||||
},
|
||||
questions: {
|
||||
queue: [],
|
||||
active: null,
|
||||
byMessage: {},
|
||||
},
|
||||
usage: {},
|
||||
scrollState: {},
|
||||
latestTodos: {},
|
||||
@@ -193,6 +199,9 @@ export interface InstanceMessageStore {
|
||||
upsertPermission: (entry: PermissionEntry) => void
|
||||
removePermission: (permissionId: string) => void
|
||||
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
||||
upsertQuestion: (entry: QuestionEntry) => void
|
||||
removeQuestion: (requestId: string) => void
|
||||
getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null
|
||||
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
||||
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
||||
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
||||
@@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
})
|
||||
}
|
||||
|
||||
const questionMap = state.questions.byMessage[options.oldId]
|
||||
if (questionMap) {
|
||||
setState("questions", "byMessage", options.newId, questionMap)
|
||||
setState("questions", (prev) => {
|
||||
const next = { ...prev }
|
||||
const nextByMessage = { ...next.byMessage }
|
||||
delete nextByMessage[options.oldId]
|
||||
next.byMessage = nextByMessage
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const pending = state.pendingParts[options.oldId]
|
||||
if (pending) {
|
||||
setState("pendingParts", options.newId, pending)
|
||||
@@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
return { entry, active }
|
||||
}
|
||||
|
||||
function upsertQuestion(entry: QuestionEntry) {
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
|
||||
|
||||
setState(
|
||||
"questions",
|
||||
produce((draft) => {
|
||||
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||
draft.byMessage[messageKey][partKey] = entry
|
||||
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
|
||||
if (existingIndex === -1) {
|
||||
draft.queue.push(entry)
|
||||
} else {
|
||||
draft.queue[existingIndex] = entry
|
||||
}
|
||||
if (!draft.active || draft.active.request.id === entry.request.id) {
|
||||
draft.active = entry
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeQuestion(requestId: string) {
|
||||
setState(
|
||||
"questions",
|
||||
produce((draft) => {
|
||||
draft.queue = draft.queue.filter((item) => item.request.id !== requestId)
|
||||
if (draft.active?.request.id === requestId) {
|
||||
draft.active = draft.queue[0] ?? null
|
||||
}
|
||||
Object.keys(draft.byMessage).forEach((messageKey) => {
|
||||
const partEntries = draft.byMessage[messageKey]
|
||||
Object.keys(partEntries).forEach((partKey) => {
|
||||
if (partEntries[partKey].request.id === requestId) {
|
||||
delete partEntries[partKey]
|
||||
}
|
||||
})
|
||||
if (Object.keys(partEntries).length === 0) {
|
||||
delete draft.byMessage[messageKey]
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getQuestionState(messageId?: string, partId?: string) {
|
||||
const messageKey = messageId ?? "__global__"
|
||||
const partKey = partId ?? "__global__"
|
||||
const entry = state.questions.byMessage[messageKey]?.[partKey]
|
||||
if (!entry) return null
|
||||
const active = state.questions.active?.request.id === entry.request.id
|
||||
return { entry, active }
|
||||
}
|
||||
|
||||
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
||||
const session = state.sessions[sessionId]
|
||||
if (!session) return
|
||||
@@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
return next
|
||||
})
|
||||
|
||||
setState("questions", "byMessage", (prev) => {
|
||||
const next = { ...prev }
|
||||
removedIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
withUsageState(sessionId, (draft) => {
|
||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||
})
|
||||
@@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
return next
|
||||
})
|
||||
|
||||
setState("questions", "byMessage", (prev) => {
|
||||
const next = { ...prev }
|
||||
messageIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
setState("usage", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[sessionId]
|
||||
@@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
replaceMessageId,
|
||||
setMessageInfo,
|
||||
getMessageInfo,
|
||||
upsertPermission,
|
||||
removePermission,
|
||||
getPermissionState,
|
||||
upsertPermission,
|
||||
removePermission,
|
||||
getPermissionState,
|
||||
upsertQuestion,
|
||||
removeQuestion,
|
||||
getQuestionState,
|
||||
|
||||
setSessionRevert,
|
||||
getSessionRevert,
|
||||
rebuildUsage,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user