Compare commits

..

52 Commits

Author SHA1 Message Date
Shantur Rathore
96f5a0ab44 Update min Server version to 0.9.1 2026-01-25 18:05:37 +00:00
Shantur Rathore
d9f7735c94 ui: show selector shortcuts inline 2026-01-25 17:55:46 +00:00
Shantur Rathore
4aae8ab720 feat(ui): add model thinking selector 2026-01-25 17:39:38 +00:00
Shantur Rathore
b83c69f002 chore(shutdown): log CLI kill timeout
Log when Electron/Tauri force-kill the CLI during shutdown so orphaned instance reports are easier to diagnose.
2026-01-25 11:03:16 +00:00
Shantur Rathore
c74e0b89f7 fix(shutdown): stop instances before app exit
Prevent desktop wrappers from SIGKILLing the CLI during shutdown, which could orphan OpenCode workspace processes. Shut down workspaces earlier/in parallel and increase the quit grace period.
2026-01-25 11:01:50 +00:00
Shantur Rathore
9ee7ff9509 feat(ui): move folder picker subtitle 2026-01-25 10:35:01 +00:00
Shantur Rathore
74a21d6418 Bump version to 0.9.1 for UI release 2026-01-25 00:27:37 +00:00
Shantur Rathore
15f390ade7 ci: allow manual release-ui on main/dev 2026-01-25 00:23:33 +00:00
Shantur Rathore
bb4e3815d1 feat(ui): show GitHub stars 2026-01-25 00:21:06 +00:00
Shantur Rathore
8fa0175b98 feat(ui): improve folder picker layout 2026-01-25 00:09:22 +00:00
Shantur Rathore
ee59622b98 Upgrade min version to 0.9.0 2026-01-24 19:23:01 +00:00
Shantur Rathore
a1452ad353 Add release notes command 2026-01-24 19:21:56 +00:00
Shantur Rathore
0c9284e57e Bump version to 0.9.0 2026-01-24 16:17:14 +00:00
Shantur Rathore
0766185ff6 fix(server): stop workspace process groups 2026-01-24 14:41:09 +00:00
Shantur Rathore
effb30d98e feat(ui): polish task steps section
Rename Tasks to Steps and remove list padding for a flush, compact steps view.
2026-01-24 10:35:15 +00:00
Shantur Rathore
4da69b5a20 feat(ui): show task model in headers
Task prompt/output headers now include provider/model metadata when available, alongside agent.
2026-01-24 10:29:02 +00:00
Shantur Rathore
3d3337c7b8 feat(ui): render task prompt/output panes
Task tool calls now show prompt, summary, and output with independent scroll; markdown rendering supports cache keys to avoid collisions.
2026-01-23 22:39:04 +00:00
Shantur Rathore
f0b43dbc68 feat(filesystem): add create-folder API for workspace picker
Adds a secure endpoint for creating a single subfolder in the current filesystem listing, and wires the non-native directory browser UI to create + enter the new folder.
2026-01-23 12:33:15 +00:00
Shantur Rathore
b0eb9aec64 Min server to 0.8.1 2026-01-22 23:05:49 +00:00
Shantur Rathore
8c48455ae5 fix(server): prefer highest available UI version
Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release.
2026-01-22 23:04:53 +00:00
Shantur Rathore
292f695395 Bump version to 0.8.1 2026-01-22 22:32:52 +00:00
Shantur Rathore
4ea710c735 feat(ui): render apply_patch multi-file diffs 2026-01-22 22:32:03 +00:00
Shantur Rathore
f5d4cb6917 refactor(ui): split ToolCall into focused modules 2026-01-22 21:54:18 +00:00
Shantur Rathore
1e53e06424 Change minVersion to 0.8.0 2026-01-22 19:16:25 +00:00
Shantur Rathore
2530cd4fc8 Bump to v0.8.0 2026-01-22 18:17:23 +00:00
Shantur Rathore
b25fb0073e fix(cloudflare): serve version.json as static asset
Avoid Workers billing for /version.json by removing worker-first routing and generating static _headers rules during manifest build.
2026-01-22 18:05:01 +00:00
Shantur Rathore
c01846f7fd ci: run release-ui in release pipeline 2026-01-22 17:29:49 +00:00
Shantur Rathore
dfd397803f Bump version to 0.7.6 2026-01-22 17:14:28 +00:00
Shantur Rathore
267f1592c4 chore: ignore local artifacts and add cloudflare lockfile 2026-01-22 16:42:47 +00:00
Shantur Rathore
668ac7fa88 ci: publish remote UI on main 2026-01-22 16:40:20 +00:00
Shantur Rathore
43a476e967 fix(cloudflare): use custom domain and remote R2 uploads 2026-01-22 16:29:23 +00:00
Shantur Rathore
adbfab5c25 feat(cloudflare): worker-hosted version.json for UI updates 2026-01-22 16:16:36 +00:00
Shantur Rathore
02f1284f7f fix(ui): emit ui-version.json and show UI source 2026-01-22 15:17:09 +00:00
Shantur Rathore
a014ce555a feat(server): auto-update UI via remote manifest 2026-01-22 15:12:32 +00:00
Shantur Rathore
db3c13c463 fix(ui): allow spaces in question custom answers
Stop trimming custom answer input on each keystroke and instead normalize answers on submit so multi-word custom responses work.
2026-01-22 09:38:38 +00:00
Shantur Rathore
7c0bf382ba fix(ui): add permission actions for unresolved requests
Render Allow/Deny buttons in the permissions control center fallback when a permission request cannot be linked to a tool-call, enabling responses for global permissions like doom_loop.
2026-01-21 14:17:08 +00:00
Shantur Rathore
6e9c5a88b4 fix(ui): allow out-of-order permission clicks
Show permission action buttons for queued tool calls while keeping keyboard shortcuts bound to the first active request. Prevent permission center list clicks from overriding keyboard-active ordering.
2026-01-21 13:26:37 +00:00
Shantur Rathore
0bf22a323f Bump version to 0.7.5 2026-01-21 12:26:22 +00:00
Shantur Rathore
cc997576cf fix(ui): stabilize question tool selection and custom answers 2026-01-21 12:25:51 +00:00
Shantur Rathore
05f193df7b fix(ui): auto-select first ready instance after refresh 2026-01-20 19:28:56 +00:00
Shantur Rathore
c9b5bb1b7a Release 0.7.4 2026-01-20 19:20:41 +00:00
Shantur Rathore
ba1013cd35 fix(ui): re-link pending question tool parts (#74) 2026-01-20 19:20:18 +00:00
Shantur Rathore
ec6428702b Bump version to 0.7.3 2026-01-20 18:49:18 +00:00
Shantur Rathore
e08ebb2057 fix(server): honor --host binding
Fixes #75
2026-01-20 18:47:40 +00:00
Shantur Rathore
9683f90f7e fix(ui): insert full paths for @file mentions 2026-01-20 18:47:40 +00:00
Shantur Rathore
06cb986aa6 fix(ui): allow Tab to select from picker
Fixes #77
2026-01-20 18:47:40 +00:00
Shantur Rathore
a85c2f1700 fix(ui): collapse prompt input after send
Fixes #76
2026-01-20 18:47:40 +00:00
Shantur Rathore
bd2a0d1bec Bump version to 0.7.2 2026-01-15 20:55:59 +00:00
Shantur Rathore
df9722cd16 fix(server): run background processes via cmd.exe on Windows 2026-01-15 20:53:13 +00:00
Shantur Rathore
dffa4907ec fix(server): validate OpenCode binary by spawning --version 2026-01-15 20:47:30 +00:00
Shantur Rathore
e567d35438 fix(server): prefer .exe/.cmd candidates on Windows 2026-01-15 20:45:14 +00:00
Shantur Rathore
62f52fc534 fix(server): spawn opencode shims via Windows shells 2026-01-15 20:43:40 +00:00
85 changed files with 6061 additions and 1089 deletions

47
.github/workflows/release-ui.yml vendored Normal file
View 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

View File

@@ -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
View File

@@ -7,4 +7,9 @@ release/
.electron-vite/
out/
.dir-locals.el
.opencode/bashOutputs/
.opencode/bashOutputs/
# Local runtime artifacts
.codenomad/
.tmp/
packages/cloudflare/.wrangler/

View 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

19
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.7.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.7.1",
"version": "0.9.1",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -1632,7 +1632,6 @@
"version": "2.10.3",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
@@ -2271,7 +2270,6 @@
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -3674,7 +3672,6 @@
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
@@ -5352,7 +5349,6 @@
},
"node_modules/pend": {
"version": "1.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
@@ -7324,7 +7320,6 @@
},
"node_modules/yauzl": {
"version": "2.10.0",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
@@ -7389,7 +7384,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.7.1",
"version": "0.9.1",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -7423,7 +7418,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.7.1",
"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,14 +7455,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.7.1",
"version": "0.9.1",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.7.1",
"version": "0.9.1",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.7.1",
"version": "0.9.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

1
packages/cloudflare/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

1515
packages/cloudflare/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,4 @@
{
"minServerVersion": "0.9.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View 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}`)

View 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 })
}

View 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)
},
}

View 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"

View File

@@ -177,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)
@@ -376,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.")
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.7.1",
"version": "0.9.1",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -3,6 +3,6 @@
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.16"
"@opencode-ai/plugin": "1.1.30"
}
}

View File

@@ -1,20 +1,30 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.7.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.7.1",
"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",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.7.1",
"version": "0.9.1",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
@@ -32,9 +32,11 @@
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"),

View File

@@ -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)
}
}
}

View File

@@ -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[] = []

View File

@@ -17,7 +17,7 @@ import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
const require = createRequire(import.meta.url)
@@ -37,6 +37,9 @@ interface CliOptions {
logDestination?: string
uiStaticDir: string
uiDevServer?: string
uiAutoUpdate: boolean
uiNoUpdate: boolean
uiManifestUrl?: string
launch: boolean
authUsername: string
authPassword?: string
@@ -66,6 +69,9 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
@@ -91,6 +97,9 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string
uiDir: string
uiDevServer?: string
uiNoUpdate?: boolean
uiAutoUpdate?: string
uiManifestUrl?: string
launch?: boolean
username: string
password?: string
@@ -101,6 +110,9 @@ function parseCliOptions(argv: string[]): CliOptions {
const normalizedHost = resolveHost(parsed.host)
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
return {
port: parsed.port,
host: normalizedHost,
@@ -111,6 +123,9 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
uiAutoUpdate,
uiNoUpdate: Boolean(parsed.uiNoUpdate),
uiManifestUrl: parsed.uiManifestUrl,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
@@ -127,10 +142,22 @@ function parsePort(input: string): number {
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
const trimmed = input?.trim()
if (!trimmed) return DEFAULT_HOST
if (trimmed === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
if (trimmed === "localhost") {
return DEFAULT_HOST
}
return trimmed
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
async function main() {
@@ -149,11 +176,13 @@ async function main() {
const eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
@@ -195,19 +224,36 @@ async function main() {
logger: logger.child({ component: "instance-events" }),
})
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
const uiResolution = await resolveUi({
serverVersion: packageJson.version,
bundledUiDir: DEFAULT_UI_STATIC_DIR,
autoUpdate: autoUpdateEnabled,
overrideUiDir: uiDirOverride,
uiDevServerUrl: options.uiDevServer,
manifestUrl: options.uiManifestUrl,
logger: logger.child({ component: "ui" }),
})
serverMeta.serverVersion = packageJson.version
serverMeta.ui = {
version: uiResolution.uiVersion,
source: uiResolution.source,
}
serverMeta.support = {
supported: uiResolution.supported,
message: uiResolution.message,
latestServerVersion: uiResolution.latestServerVersion,
latestServerUrl: uiResolution.latestServerUrl,
minServerVersion: uiResolution.minServerVersion,
}
const server = createHttpServer({
host: options.host,
port: options.port,
@@ -219,8 +265,8 @@ async function main() {
serverMeta,
instanceStore,
authManager,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
@@ -240,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)

View File

@@ -93,6 +93,7 @@ export function createHttpServer(deps: HttpServerDeps) {
})
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
app.register(cors, {
origin: (origin, cb) => {
@@ -113,10 +114,17 @@ export function createHttpServer(deps: HttpServerDeps) {
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
cb(null, true)
return
}
cb(null, false)
},
@@ -275,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)

View File

@@ -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)
}
})
}

View File

@@ -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>()

View 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")
})
})

View 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]),
}
}

View File

@@ -187,16 +187,27 @@ 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")
@@ -225,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) {
@@ -244,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

View File

@@ -1,10 +1,45 @@
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> {
@@ -73,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: 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 }
@@ -221,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 = () => {
@@ -236,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)
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.7.1",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "tauri dev",

View File

@@ -34,6 +34,8 @@ 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") {
let mut display = url.to_string();
@@ -276,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);
@@ -290,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);

View File

@@ -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);
});
}
_ => {}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.7.1",
"version": "0.9.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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)

View File

@@ -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>

View File

@@ -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) && (

View 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>
)

View File

@@ -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

View File

@@ -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>

View File

@@ -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)
}
@@ -901,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>
</>
)}

View File

@@ -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>

View File

@@ -7,8 +7,7 @@ import {
getPermissionQueue,
getQuestionQueue,
getQuestionEnqueuedAtForInstance,
setActivePermissionIdForInstance,
setActiveQuestionIdForInstance,
sendPermissionResponse,
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
@@ -132,6 +131,45 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
const setPermissionBusy = (permissionId: string, busy: boolean) => {
setPermissionSubmitting((prev) => {
const next = new Set(prev)
if (busy) next.add(permissionId)
else next.delete(permissionId)
return next
})
}
const setPermissionItemError = (permissionId: string, message: string | null) => {
setPermissionError((prev) => {
const next = new Map(prev)
if (!message) next.delete(permissionId)
else next.set(permissionId, message)
return next
})
}
async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
const permissionId = permission?.id
if (!permissionId) return
if (permissionSubmitting().has(permissionId)) return
setPermissionBusy(permissionId, true)
setPermissionItemError(permissionId, null)
try {
const sessionId = getPermissionSessionId(permission) || ""
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
} catch (error) {
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
} finally {
setPermissionBusy(permissionId, false)
}
}
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
@@ -265,19 +303,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
return count === 1 ? "1 question" : `${count} questions`
}
const handleActivate = () => {
if (item.kind === "permission") {
setActivePermissionIdForInstance(props.instanceId, item.id)
} else {
setActiveQuestionIdForInstance(props.instanceId, item.id)
}
}
return (
<div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
role="listitem"
onClick={handleActivate}
>
<div class="permission-center-item-header">
<div class="permission-center-item-heading">
@@ -315,17 +344,52 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
</div>
</div>
<Show
when={resolved()}
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{primaryTitle()}</code>
<Show
when={resolved()}
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{primaryTitle()}</code>
</div>
<Show when={item.kind === "permission"}>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
>
Allow Once
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
>
Always Allow
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
>
Deny
</button>
</div>
</div>
<Show when={permissionError().get(item.id)}>
{(err) => <div class="tool-call-permission-error">{err()}</div>}
</Show>
</Show>
<Show when={item.kind !== "permission"}>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</Show>
</div>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</div>
}
>
}
>
{(data) => (
<ToolCall
toolCall={data().toolPart}

View File

@@ -604,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
}
}
setExpandState("normal")
clearPrompt()
// Ignore attachments for slash commands, but keep them for next prompt.
@@ -843,7 +844,10 @@ export default function PromptInput(props: PromptInputProps) {
const currentPrompt = prompt()
const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
@@ -887,7 +891,7 @@ export default function PromptInput(props: PromptInputProps) {
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${filename}`
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt)

View 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>
)
}

View File

@@ -1,16 +1,20 @@
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { messageStoreBus } from "../stores/message-v2/bus"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { TextPart, RenderCache } from "../types/message"
import { resolveToolRenderer } from "./tool-call/renderers"
import { QuestionToolBlock } from "./tool-call/question-block"
import { PermissionToolBlock } from "./tool-call/permission-block"
import { createAnsiContentRenderer } from "./tool-call/ansi-render"
import { createDiffContentRenderer } from "./tool-call/diff-render"
import { createMarkdownContentRenderer } from "./tool-call/markdown-render"
import { extractDiagnostics, diagnosticFileName } from "./tool-call/diagnostics"
import { renderDiagnosticsSection } from "./tool-call/diagnostics-section"
import type {
DiffPayload,
DiffRenderOptions,
@@ -23,15 +27,11 @@ import type {
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { escapeHtml } from "../lib/markdown"
const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk").ToolState
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
@@ -62,162 +62,7 @@ interface ToolCallProps {
interface LspRangePosition {
line?: number
character?: number
}
interface LspRange {
start?: LspRangePosition
}
interface LspDiagnostic {
message?: string
severity?: number
range?: LspRange
}
interface DiagnosticEntry {
id: string
severity: number
tone: "error" | "warning" | "info"
label: string
icon: string
message: string
filePath: string
displayPath: string
line: number
column: number
}
function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
}
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
if (!state) return []
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
if (!supportsMetadata) return []
const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
if (!diagnosticsMap) return []
const preferredPath = [
input.filePath,
metadata.filePath,
metadata.filepath,
input.path,
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const prioritizedEntries = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
return normalized === normalizedPreferred
})
if (prioritizedEntries.length === 0) return []
const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) {
if (!Array.isArray(list)) continue
const normalizedPath = normalizeDiagnosticPath(pathKey)
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
}
return entries.sort((a, b) => a.severity - b.severity)
}
function diagnosticFileName(entries: DiagnosticEntry[]) {
const first = entries[0]
return first ? first.displayPath : ""
}
function renderDiagnosticsSection(
entries: DiagnosticEntry[],
expanded: boolean,
toggle: () => void,
fileLabel: string,
) {
if (entries.length === 0) return null
return (
<div class="tool-call-diagnostics-wrapper">
<button
type="button"
class="tool-call-diagnostics-heading"
aria-expanded={expanded}
onClick={toggle}
>
<span class="tool-call-icon" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">
:L{entry.line || "-"}:C{entry.column || "-"}
</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig()
@@ -252,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
return "noversion"
})
const messageVersionAccessor = createMemo(() => props.messageVersion)
const partVersionAccessor = createMemo(() => props.partVersion)
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
useGlobalCache({
instanceId: () => props.instanceId,
@@ -269,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
const permissionDiffCache = createVariantCache("permission-diff")
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
const ansiFinalCache = createVariantCache("ansi-final")
const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = ""
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
const pendingPermission = createMemo(() => {
@@ -554,15 +400,17 @@ export default function ToolCall(props: ToolCallProps) {
const activeKey = activePermissionKey()
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
const permission = permissionDetails()
if (!permission || !isPermissionActive()) return
if (event.key === "Enter") {
event.preventDefault()
handlePermissionResponse("once")
void handlePermissionResponse(permission, "once")
} else if (event.key === "a" || event.key === "A") {
event.preventDefault()
handlePermissionResponse("always")
void handlePermissionResponse(permission, "always")
} else if (event.key === "d" || event.key === "D") {
event.preventDefault()
handlePermissionResponse("reject")
void handlePermissionResponse(permission, "reject")
}
}
document.addEventListener("keydown", handler)
@@ -573,7 +421,6 @@ export default function ToolCall(props: ToolCallProps) {
const [questionError, setQuestionError] = createSignal<string | null>(null)
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
const [questionCustomDraft, setQuestionCustomDraft] = createSignal<Record<string, string[]>>({})
function isTextInputFocused() {
const active = document.activeElement
@@ -590,7 +437,10 @@ export default function ToolCall(props: ToolCallProps) {
return
}
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
const normalized = request.questions.map((_, index) => answers[index] ?? [])
const normalized = request.questions.map((_, index) => {
const row = answers[index] ?? []
return row.map((value) => value.trim()).filter((value) => value.length > 0)
})
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
setQuestionError("Please answer all questions before submitting.")
return
@@ -684,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
const renderer = createMemo(() => resolveToolRenderer(toolName()))
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light"
const { renderAnsiContent } = createAnsiContentRenderer({
ansiRunningCache,
ansiFinalCache,
scrollHelpers,
partVersion: partVersionAccessor,
})
let cachedHtml: string | undefined
const cached = cacheHandle.get<RenderCache>()
const currentMode = diffMode()
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
cachedHtml = cached.html
}
const { renderDiffContent } = createDiffContentRenderer({
preferences,
setDiffViewMode,
isDark,
diffCache,
permissionDiffCache,
scrollHelpers,
handleScrollRendered,
onContentRendered: props.onContentRendered,
})
const handleModeChange = (mode: DiffViewMode) => {
setDiffViewMode(mode)
}
const handleDiffRendered = () => {
if (!options?.disableScrollTracking) {
handleScrollRendered()
}
props.onContentRendered?.()
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
>
Split
</button>
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
>
Unified
</button>
</div>
</div>
<ToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={diffMode()}
cachedHtml={cachedHtml}
cacheEntryParams={cacheHandle.params()}
onRendered={handleDiffRendered}
/>
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
</div>
)
}
function renderAnsiContent(options: AnsiRenderOptions) {
if (!options.content) {
return null
}
const size = options.size || "default"
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
const cached = cacheHandle.get<AnsiRenderCache>()
const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined
const isRunningVariant = options.variant === "running"
let nextCache: AnsiRenderCache
if (isRunningVariant) {
const content = options.content
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) {
const detectedAnsi = hasAnsi(content)
if (detectedAnsi) {
runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true }
} else {
runningAnsiRenderer.reset()
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
}
} else {
const delta = content.slice(cached.text.length)
if (delta.length === 0) {
nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) {
runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true }
} else if (cached.hasAnsi) {
const htmlChunk = runningAnsiRenderer.render(delta)
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
} else {
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
}
}
runningAnsiSource = nextCache.text
cacheHandle.set(nextCache)
} else {
if (cached && cached.text === options.content) {
nextCache = { ...cached, mode }
} else {
const detectedAnsi = hasAnsi(options.content)
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
cacheHandle.set(nextCache)
}
}
if (options.requireAnsi && !nextCache.hasAnsi) {
return null
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{scrollHelpers.renderSentinel()}
</div>
)
}
function renderMarkdownContent(options: MarkdownRenderOptions) {
if (!options.content) {
return null
}
const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const state = toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) {
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
{scrollHelpers.renderSentinel()}
</div>
)
}
const partId = toolCallMemo()?.id
if (!partId) {
throw new Error("Tool call markdown requires a part id")
}
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
const handleMarkdownRendered = () => {
handleScrollRendered()
props.onContentRendered?.()
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown
part={markdownPart}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}
/>
{scrollHelpers.renderSentinel()}
</div>
)
}
const messageVersionAccessor = createMemo(() => props.messageVersion)
const partVersionAccessor = createMemo(() => props.partVersion)
const { renderMarkdownContent } = createMarkdownContentRenderer({
toolState,
partId: toolCallIdentifier,
partVersion: partVersionAccessor,
instanceId: props.instanceId,
sessionId: props.sessionId,
isDark,
scrollHelpers,
handleScrollRendered,
onContentRendered: props.onContentRendered,
})
const rendererContext: ToolRendererContext = {
toolCall: toolCallMemo,
@@ -936,11 +630,8 @@ export default function ToolCall(props: ToolCallProps) {
return renderer().renderBody(rendererContext)
}
async function handlePermissionResponse(response: "once" | "always" | "reject") {
const permission = permissionDetails()
if (!permission || !isPermissionActive()) {
return
}
async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
if (!permission) return
setPermissionSubmitting(true)
setPermissionError(null)
try {
@@ -968,283 +659,33 @@ export default function ToolCall(props: ToolCallProps) {
}
const renderPermissionBlock = () => {
const permission = permissionDetails()
if (!permission) return null
const active = isPermissionActive()
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
const diffPathRaw = (() => {
if (typeof metadata.filePath === "string") {
return metadata.filePath as string
}
if (typeof metadata.path === "string") {
return metadata.path as string
}
return undefined
})()
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
const renderPermissionBlock = () => (
<PermissionToolBlock
permission={permissionDetails}
active={isPermissionActive}
submitting={permissionSubmitting}
error={permissionError}
renderDiff={renderDiffContent}
fallbackSessionId={() => props.sessionId}
onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
/>
)
return (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
</div>
<div class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{getPermissionDisplayTitle(permission)}</code>
</div>
<Show when={diffPayload}>
{(payload) => (
<div class="tool-call-permission-diff">
{renderDiffContent(payload(), {
variant: "permission-diff",
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})}
</div>
)}
</Show>
<Show
when={active}
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("once")}
>
Allow Once
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("always")}
>
Always Allow
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("reject")}
>
Deny
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
</div>
</div>
<Show when={permissionError()}>
<div class="tool-call-permission-error">{permissionError()}</div>
</Show>
</Show>
</div>
</div>
)
}
const renderQuestionBlock = () => {
const state = toolState()
const request = questionDetails()
const isQuestionTool = toolName() === "question"
if (!request && !isQuestionTool) return null
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
const questions = Array.isArray(questionsSource) ? questionsSource : []
if (questions.length === 0) return null
const requestId = request?.id ?? (state as any)?.input?.requestID ?? `question-${toolCallMemo()?.id ?? "unknown"}`
const active = Boolean(request && isQuestionActive())
const completedAnswers = Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined
const answers = completedAnswers ?? questionDraftAnswers()[requestId] ?? []
const customInputs = questionCustomDraft()[requestId] ?? []
const updateAnswer = (questionIndex: number, next: string[]) => {
if (!active) return
setQuestionDraftAnswers((prev) => {
const current = prev[requestId] ?? []
const updated = [...current]
updated[questionIndex] = next
return { ...prev, [requestId]: updated }
})
}
const updateCustom = (questionIndex: number, value: string) => {
if (!active) return
setQuestionCustomDraft((prev) => {
const current = prev[requestId] ?? []
const updated = [...current]
updated[questionIndex] = value
return { ...prev, [requestId]: updated }
})
}
const toggleOption = (questionIndex: number, label: string) => {
const info = questions[questionIndex]
const multi = info?.multiple === true
const existing = answers[questionIndex] ?? []
if (multi) {
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
updateAnswer(questionIndex, next)
return
}
updateAnswer(questionIndex, [label])
}
const submitDisabled = () => {
if (!active) return true
if (questionSubmitting()) return true
return questions.some((_, index) => (answers[index]?.length ?? 0) === 0)
}
const showButtons = () => active
return (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">
{active ? "Question Required" : request ? "Question Queued" : "Questions"}
</span>
<span class="tool-call-permission-type">{questions.length === 1 ? "Question" : "Questions"}</span>
</div>
<div class="tool-call-permission-body">
<div class="flex flex-col gap-4">
<For each={questions}>
{(q, index) => {
const i = () => index()
const multi = () => q?.multiple === true
const selected = () => answers[i()] ?? []
const customValue = () => customInputs[i()] ?? ""
const inputType = () => (multi() ? "checkbox" : "radio")
const groupName = () => `question-${requestId}-${i()}`
return (
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs">
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
</div>
<Show when={multi()}>
<div class="text-xs text-muted">Multiple</div>
</Show>
</div>
<div class="mt-1 text-sm font-medium">{q?.question}</div>
<div class="mt-3 flex flex-col gap-1">
<For each={q?.options ?? []}>
{(opt) => {
const checked = () => selected().includes(opt.label)
return (
<label
class={`flex items-start gap-2 py-1 ${active ? "cursor-pointer" : request ? "opacity-80" : ""}`}
title={opt.description}
>
<input
type={inputType()}
name={groupName()}
checked={checked()}
disabled={!active || questionSubmitting()}
onChange={() => toggleOption(i(), opt.label)}
/>
<div class="flex flex-col">
<div class="text-sm leading-tight">{opt.label}</div>
<div class="text-xs text-muted leading-tight">{opt.description}</div>
</div>
</label>
)
}}
</For>
<Show when={active}>
<div class="mt-2 flex items-center gap-2">
<input
class="flex-1 rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
type="text"
placeholder="Type your own answer"
value={customValue()}
disabled={!active || questionSubmitting()}
onInput={(e) => updateCustom(i(), e.currentTarget.value)}
/>
<button
type="button"
class="tool-call-permission-button"
disabled={!active || questionSubmitting() || !customValue().trim()}
onClick={() => {
const value = customValue().trim()
if (!value) return
updateCustom(i(), value)
toggleOption(i(), value)
}}
>
{multi() ? "Toggle" : "Select"}
</button>
</div>
</Show>
</div>
</div>
)
}}
</For>
<Show when={showButtons()}>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={submitDisabled()}
onClick={() => handleQuestionSubmit()}
>
Submit
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={questionSubmitting()}
onClick={() => handleQuestionDismiss()}
>
Dismiss
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Submit</span>
<kbd class="kbd">Esc</kbd>
<span>Dismiss</span>
</div>
<Show when={questionError()}>
<div class="tool-call-permission-error">{questionError()}</div>
</Show>
</div>
</Show>
<Show when={!active && request}>
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
</Show>
</div>
</div>
</div>
)
}
const renderQuestionBlock = () => (
<QuestionToolBlock
toolName={toolName}
toolState={toolState}
toolCallId={toolCallIdentifier}
request={questionDetails}
active={isQuestionActive}
submitting={questionSubmitting}
error={questionError}
draftAnswers={questionDraftAnswers}
setDraftAnswers={setQuestionDraftAnswers}
onSubmit={() => void handleQuestionSubmit()}
onDismiss={() => void handleQuestionDismiss()}
/>
)
createEffect(() => {
const request = questionDetails()
@@ -1260,11 +701,7 @@ export default function ToolCall(props: ToolCallProps) {
const initial = request.questions.map(() => [])
return { ...prev, [requestId]: initial }
})
setQuestionCustomDraft((prev) => {
if (prev[requestId]) return prev
const initial = request.questions.map(() => "")
return { ...prev, [requestId]: initial }
})
})
const status = () => toolState()?.status || ""

View 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 }
}

View 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>
)
}

View 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 : ""
}

View 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 }
}

View 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 }
}

View 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>
)
}

View 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>
)
}

View 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>
)
},
}

View File

@@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types"
import { bashRenderer } from "./bash"
import { defaultRenderer } from "./default"
import { editRenderer } from "./edit"
import { applyPatchRenderer } from "./apply-patch"
import { patchRenderer } from "./patch"
import { readRenderer } from "./read"
import { taskRenderer } from "./task"
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
readRenderer,
writeRenderer,
editRenderer,
applyPatchRenderer,
patchRenderer,
webfetchRenderer,
todoRenderer,

View File

@@ -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>
)
},

View File

@@ -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,

View File

@@ -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 {

View File

@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
return "📁"
case "patch":
return "🔧"
case "apply_patch":
return "🔧"
default:
return "🔧"
}
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
case "todowrite":
case "todoread":
return "Plan"
case "apply_patch":
return "Apply patch"
default: {
const normalized = tool.replace(/^opencode_/, "")
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
return "Planning..."
case "patch":
return "Preparing patch..."
case "apply_patch":
return "Preparing apply_patch..."
default:
return "Working..."
}

View File

@@ -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>

View 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>
)
}

View File

@@ -8,6 +8,7 @@ import type {
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemCreateFolderResponse,
FileSystemListResponse,
InstanceData,
ServerMeta,
@@ -224,6 +225,13 @@ export const serverApi = {
const query = params.toString()
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
},
createFileSystemFolder(parentPath: string | undefined, name: string): Promise<FileSystemCreateFolderResponse> {
return request<FileSystemCreateFolderResponse>("/api/filesystem/folders", {
method: "POST",
body: JSON.stringify({ parentPath, name }),
})
},
readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
},

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View File

@@ -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",
})
}

View 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 }

View File

@@ -92,6 +92,19 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
}
}
function ensureActiveInstanceSelected(): void {
const current = activeInstanceId()
const instanceMap = instances()
if (current && instanceMap.has(current)) return
for (const [id, instance] of instanceMap.entries()) {
if (instance.status === "ready") {
setActiveInstanceId(id)
return
}
}
}
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
const mapped = workspaceDescriptorToInstance(descriptor)
if (instances().has(descriptor.id)) {
@@ -102,6 +115,9 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
if (descriptor.status === "ready") {
attachClient(descriptor)
// If no tab is currently selected (common after UI refresh),
// auto-select the first ready instance.
ensureActiveInstanceSelected()
}
}
@@ -225,15 +241,18 @@ async function hydrateInstanceData(instanceId: string) {
}
}
void (async function initializeWorkspaces() {
void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
workspaces.forEach((workspace) => upsertWorkspace(workspace))
// After a UI refresh, we may have instances but no active selection.
ensureActiveInstanceSelected()
} catch (error) {
log.error("Failed to load workspaces", error)
}
})()
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
function handleWorkspaceEvent(event: WorkspaceEventPayload) {

View File

@@ -39,6 +39,7 @@ export interface Preferences {
lastUsedBinary?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
modelThinkingSelections: Record<string, string>
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
@@ -71,6 +72,7 @@ const defaultPreferences: Preferences = {
showTimelineTools: true,
environmentVariables: {},
modelRecents: [],
modelThinkingSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
@@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
const modelThinkingSelections = {
...defaultPreferences.modelThinkingSelections,
...(sanitized.modelThinkingSelections ?? {}),
}
return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
@@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables,
modelRecents,
modelThinkingSelections,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
@@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
}
}
function getModelKey(model: { providerId: string; modelId: string }): string {
return `${model.providerId}/${model.modelId}`
}
function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined {
if (!model.providerId || !model.modelId) return undefined
return preferences().modelThinkingSelections?.[getModelKey(model)]
}
function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void {
if (!model.providerId || !model.modelId) return
const key = getModelKey(model)
const current = preferences().modelThinkingSelections?.[key]
if (current === value) return
updateConfig((draft) => {
const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) }
if (!value) {
delete selections[key]
} else {
selections[key] = value
}
draft.preferences = normalizePreferences({
...draft.preferences,
modelThinkingSelections: selections,
})
})
}
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
@@ -527,6 +564,8 @@ export {
addEnvironmentVariable,
removeEnvironmentVariable,
addRecentModelPreference,
getModelThinkingSelection,
setModelThinkingSelection,
setAgentModelPreference,
getAgentModelPreference,
setDiffViewMode,
@@ -540,4 +579,3 @@ export {
}

View File

@@ -1,25 +1,24 @@
import { createEffect, createSignal } from "solid-js"
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
import type { SupportMeta } from "../../../server/src/api-types"
import { getServerMeta } from "../lib/server-meta"
import { serverEvents } from "../lib/server-events"
import { showToastNotification, ToastHandle } from "../lib/notifications"
import { getLogger } from "../lib/logger"
import { hasInstances, showFolderSelection } from "./ui"
const log = getLogger("actions")
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
let initialized = false
let visibilityEffectInitialized = false
let activeToast: ToastHandle | null = null
let activeToastVersion: string | null = null
let activeToastKey: string | null = null
function dismissActiveToast() {
if (activeToast) {
activeToast.dismiss()
activeToast = null
activeToastVersion = null
activeToastKey = null
}
}
@@ -30,28 +29,34 @@ function ensureVisibilityEffect() {
visibilityEffectInitialized = true
createEffect(() => {
const release = availableRelease()
const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection())
const support = supportInfo()
const shouldShow = Boolean(support && support.supported === false) && (!hasInstances() || showFolderSelection())
if (!shouldShow || !release) {
if (!shouldShow || !support || support.supported !== false) {
dismissActiveToast()
return
}
if (!activeToast || activeToastVersion !== release.version) {
const key = `${support.minServerVersion ?? "unknown"}:${support.latestServerVersion ?? "unknown"}`
if (!activeToast || activeToastKey !== key) {
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
title: support.message ?? "Upgrade required",
message: support.latestServerVersion
? `Update to CodeNomad ${support.latestServerVersion} to use the latest UI.`
: "Update CodeNomad to use the latest UI.",
variant: "info",
duration: Number.POSITIVE_INFINITY,
position: "bottom-right",
action: {
label: "View release",
href: release.url,
},
action: support.latestServerUrl
? {
label: "Get update",
href: support.latestServerUrl,
}
: undefined,
})
activeToastVersion = release.version
activeToastKey = key
}
})
}
@@ -64,32 +69,17 @@ export function initReleaseNotifications() {
ensureVisibilityEffect()
void refreshFromMeta()
serverEvents.on("app.releaseAvailable", (event) => {
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
applyRelease(typedEvent.release)
})
}
async function refreshFromMeta() {
try {
const meta = await getServerMeta(true)
if (meta.latestRelease) {
applyRelease(meta.latestRelease)
}
setSupportInfo(meta.support ?? null)
} catch (error) {
log.warn("Unable to load server metadata for release info", error)
log.warn("Unable to load server metadata for support info", error)
}
}
function applyRelease(release: LatestReleaseInfo | null | undefined) {
if (!release) {
setAvailableRelease(null)
return
}
setAvailableRelease(release)
}
export function useAvailableRelease() {
return availableRelease
export function useSupportInfo() {
return supportInfo
}

View File

@@ -1,8 +1,8 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
import { sessions, withSession } from "./session-state"
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { providers, sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
@@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api"
const log = getLogger("actions")
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
if (!model.providerId || !model.modelId) return []
const instanceProviders = providers().get(instanceId) || []
const provider = instanceProviders.find((p) => p.id === model.providerId)
const match = provider?.models.find((m) => m.id === model.modelId)
return match?.variantKeys ?? []
}
function getThinkingVariantToSend(instanceId: string, model: { providerId: string; modelId: string }): string | undefined {
const selected = getModelThinkingSelection(model)
if (!selected) return undefined
const keys = getVariantKeysForModel(instanceId, model)
if (keys.length === 0) return undefined
return keys.includes(selected) ? selected : undefined
}
const ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -170,6 +186,12 @@ async function sendMessage(
modelID: session.model.modelId,
},
}),
...(session.model.providerId &&
session.model.modelId &&
(() => {
const variant = getThinkingVariantToSend(instanceId, session.model)
return variant ? { variant } : {}
})()),
}
log.info("sendMessage", {
@@ -215,6 +237,7 @@ async function executeCustomCommand(
messageID: string
agent?: string
model?: string
variant?: string
} = {
command: commandName,
arguments: args,
@@ -227,6 +250,8 @@ async function executeCustomCommand(
if (session.model.providerId && session.model.modelId) {
body.model = `${session.model.providerId}/${session.model.modelId}`
const variant = getThinkingVariantToSend(instanceId, session.model)
if (variant) body.variant = variant
}
await requestData(

View File

@@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
providerId: provider.id,
limit: model.limit,
cost: model.cost,
variantKeys: Object.keys(model.variants ?? {}),
})),
}))

View File

@@ -39,6 +39,7 @@ import { loadMessages } from "./session-api"
import {
applyPartUpdateV2,
replaceMessageIdV2,
reconcilePendingQuestionsV2,
upsertMessageInfoV2,
upsertPermissionV2,
upsertQuestionV2,
@@ -230,6 +231,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
if (part.type === "tool" && part.tool === "question") {
// Questions can arrive before their tool part exists; re-link now.
reconcilePendingQuestionsV2(instanceId, sessionId)
}
updateSessionInfo(instanceId, sessionId)
} else if (event.type === "message.updated") {

View File

@@ -81,6 +81,14 @@
width: auto;
}
.directory-browser-current-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
justify-content: flex-end;
}
.directory-browser-close {
display: inline-flex;

View File

@@ -27,6 +27,10 @@
@apply items-start;
}
.selector-trigger-label--top {
@apply self-start;
}
.selector-trigger-primary {
@apply text-sm font-medium truncate;
color: var(--text-primary);
@@ -46,6 +50,15 @@
color: var(--text-muted);
}
.selector-trigger-hint {
@apply flex-shrink-0;
color: var(--text-muted);
}
.selector-trigger-hint--top {
@apply self-start mt-0.5;
}
.selector-popover {
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
background-color: var(--surface-base);

View File

@@ -217,6 +217,16 @@
@apply flex items-center justify-between gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
position: sticky;
top: 0;
z-index: 2;
}
/* Diff shell already provides the scroll container.
Avoid nested scroll areas inside the diff viewer. */
.tool-call-diff-shell .tool-call-diff-viewer {
max-height: none;
overflow: visible;
}
.tool-call-diff-toolbar-label {
@@ -423,6 +433,19 @@
background-clip: padding-box;
}
/* apply_patch multi-file layout */
.tool-call-apply-patch {
@apply flex flex-col;
}
.tool-call-apply-patch-file {
margin-top: 0.75rem;
}
.tool-call-apply-patch-file:first-child {
margin-top: 0;
}
.tool-call-section h4 {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);

View File

@@ -1,7 +1,74 @@
.tool-call-task-container {
.tool-call-task-sections {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: 0;
}
.tool-call-task-section {
border: 1px solid var(--border-base);
overflow: hidden;
background-color: transparent;
border-radius: 0;
}
.tool-call-task-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
font-family: var(--font-family-mono);
font-size: 13px;
color: inherit;
}
.tool-call-task-section-title {
font-weight: var(--font-weight-semibold);
}
.tool-call-task-section-meta {
font-family: var(--font-family-mono);
color: var(--text-muted);
}
.tool-call-task-section-body {
background-color: var(--surface-code);
}
.tool-call-task-section-body .tool-call-markdown {
padding: 12px;
}
.tool-call-task-container {
padding: 0;
}
/* Steps list should be flush (no inset padding). */
.tool-call-task-section-body .tool-call-task-container.tool-call-markdown {
padding: 0;
}
/* Keep task lists compact vs prompt/output panes. */
.tool-call-task-container.tool-call-markdown {
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) / 2);
}
/* Prompt + output panes: slightly taller than tasks. */
.tool-call-task-section-body > .tool-call-markdown:not(.tool-call-task-container) {
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) * 2 / 3);
}
.tool-call-task-empty {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
color: var(--text-muted);
padding: 0.5rem;
}
.tool-call-task-summary {
display: flex;
flex-direction: column;

View File

@@ -115,29 +115,6 @@ session-sidebar-controls .selector-trigger-primary {
@apply flex flex-col gap-1 w-full;
}
.sidebar-selector-hint {
@apply flex justify-center text-xs w-full;
color: var(--text-muted);
}
.sidebar-selector-hints {
@apply flex items-center gap-2 w-full;
justify-content: space-between;
}
.sidebar-selector-hint--left,
.sidebar-selector-hint--right {
@apply flex-1;
}
.sidebar-selector-hint--left {
justify-content: flex-start;
}
.sidebar-selector-hint--right {
justify-content: flex-end;
}
.session-header-hints {
@apply flex-shrink-0;
}

View File

@@ -103,6 +103,25 @@
box-shadow: 0 0 0 2px var(--accent-primary);
}
/* Form controls */
.form-input {
@apply w-full px-3 py-2 text-sm;
background-color: var(--surface-base);
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
color: var(--text-primary);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px var(--accent-primary);
}
/* Shared animations */
@keyframes pulse {
0%, 100% { opacity: 1; }

View File

@@ -85,6 +85,7 @@ export interface Model {
id: string
name: string
providerId: string
variantKeys?: string[]
limit?: {
context?: number
output?: number

View File

@@ -1,10 +1,26 @@
import fs from "fs"
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
import { resolve } from "path"
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
const uiVersion = uiPackageJson.version ?? "0.0.0"
export default defineConfig({
root: "./src/renderer",
plugins: [solid()],
plugins: [
solid(),
{
name: "emit-ui-version",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "ui-version.json",
source: JSON.stringify({ uiVersion }, null, 2),
})
},
},
],
css: {
postcss: "./postcss.config.js",
},