Compare commits

...

72 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
Shantur Rathore
69f221942c Bump version to 0.7.1 2026-01-15 08:39:06 +00:00
Shantur Rathore
7749225f71 fix(ui): restore pasted text expand controls\n\nFixes #67 2026-01-15 08:36:56 +00:00
Shantur Rathore
ae322c53cc fix(ui): correct Go to Session navigation across instances 2026-01-15 08:26:49 +00:00
Shantur Rathore
37da426ab4 Bump version to 0.7.0 2026-01-14 21:36:45 +00:00
Shantur Rathore
591f55bef9 fix(ui): render prompt attachments above input 2026-01-14 21:35:18 +00:00
Shantur Rathore
aabaadbe1d fix(ui): expand prompt via rows, keep placeholder padding 2026-01-14 21:28:04 +00:00
Shantur Rathore
3ab14e8de6 Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
2026-01-14 18:22:56 +00:00
Shantur Rathore
40634138bc feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
2026-01-14 18:18:14 +00:00
bizzkoot
b17087b610 refactor: remove mobile-specific placeholder text for simplicity
- Remove isMobileWidth signal and updateMobileWidth resize listener
- Use same placeholder text for all devices/platforms
- "Type your message, @file, @agent, or paste images and text..."

Simplifies implementation per dev feedback - one approach for all
2026-01-13 06:48:33 +08:00
bizzkoot
71f58e7c5f refactor: simplify expand chat input to 2-state with optimized button layout
- Remove 3-state logic (normal/50%/80%) - now only normal/expanded
- Remove double-click detection and tooltips for simplicity
- Remove platform-specific behavior (same UX for Electron and web)
- Optimize button layout: reduce from 36px to 28px to fit 3 buttons
- Position expand button above history buttons in vertical stack
- Keep 15-line expanded height (360px, capped to available space)

Per upstream dev feedback to keep it simple with one approach
2026-01-13 06:45:56 +08:00
Shantur Rathore
927e4e1281 perf(ui): reduce session list churn and message block invalidation 2026-01-12 16:37:09 +00:00
bizzkoot
2e56a5e9f4 feat: implement platform-specific expand chat input with mobile optimizations
- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
2026-01-12 20:40:19 +08:00
bizzkoot
296d07a0d6 Move expand chat input doc to dev-docs and remove empty plans folder 2026-01-12 05:24:24 +08:00
bizzkoot
0d8a844af8 feat: implement expandable chat input with double-click detection and gradient tooltip
- Add expand button with Maximize2/Minimize2 icons
- Implement 3-state height management (normal/50%/80%)
- Smart double-click detection with 300ms delay
- Height calculation based on session-view - 200px message space
- Custom CSS tooltip with dark gradient background and backdrop blur
- Send button anchored at bottom via margin-top: auto
- Smooth CSS transitions throughout
- Double-click at 80% now reduces to 50% (not normal)
- Removed all debug console.log statements
2026-01-11 21:59:28 +08:00
bizzkoot
bf9cef4cd5 docs: add expand chat input implementation plan 2026-01-11 20:17:19 +08:00
bizzkoot
9dde33aba7 style: add expand button positioning and styles 2026-01-11 20:09:13 +08:00
bizzkoot
0fefff3b0a feat: integrate ExpandButton and apply dynamic height to textarea 2026-01-11 20:07:48 +08:00
bizzkoot
1122c19648 feat: create ExpandButton component with click/double-click logic 2026-01-11 20:05:16 +08:00
bizzkoot
f06359a1fc feat: add expand state signal and height calculation helpers 2026-01-11 20:04:25 +08:00
Shantur Rathore
72f420b6f6 feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
2026-01-10 09:46:23 +00:00
118 changed files with 9031 additions and 1379 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 }} release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit secrets: inherit
release-ui:
needs: prepare-release
permissions:
contents: read
uses: ./.github/workflows/release-ui.yml
secrets: inherit
publish-server: publish-server:
needs: needs:
- prepare-release - prepare-release

7
.gitignore vendored
View File

@@ -7,4 +7,9 @@ release/
.electron-vite/ .electron-vite/
out/ out/
.dir-locals.el .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

27
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.6.0", "version": "0.9.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.6.0", "version": "0.9.1",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
@@ -1096,9 +1096,9 @@
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.1.1", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==", "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
@@ -1632,7 +1632,6 @@
"version": "2.10.3", "version": "2.10.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -2271,7 +2270,6 @@
}, },
"node_modules/buffer-crc32": { "node_modules/buffer-crc32": {
"version": "0.2.13", "version": "0.2.13",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "*" "node": "*"
@@ -3674,7 +3672,6 @@
}, },
"node_modules/fd-slicer": { "node_modules/fd-slicer": {
"version": "1.1.0", "version": "1.1.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pend": "~1.2.0" "pend": "~1.2.0"
@@ -5352,7 +5349,6 @@
}, },
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@@ -7324,7 +7320,6 @@
}, },
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-crc32": "~0.2.3", "buffer-crc32": "~0.2.3",
@@ -7389,7 +7384,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.6.0", "version": "0.9.1",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server" "@neuralnomads/codenomad": "file:../server"
@@ -7423,7 +7418,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.6.0", "version": "0.9.1",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",
@@ -7433,12 +7428,14 @@
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"bin": { "bin": {
"codenomad": "dist/bin.js" "codenomad": "dist/bin.js"
}, },
"devDependencies": { "devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
@@ -7458,18 +7455,18 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.6.0", "version": "0.9.1",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
} }
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.6.0", "version": "0.9.1",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.1", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.6.0", "version": "0.9.1",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"workspaces": { "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

@@ -1,4 +1,6 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs" import { existsSync } from "fs"
import { dirname, join } from "path" import { dirname, join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false let showingLoadingScreen = false
let preloadingView: BrowserView | null = null let preloadingView: BrowserView | null = null
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
loadLoadingScreen(mainWindow) loadLoadingScreen(mainWindow)
} }
function isBootstrapTokenUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
} catch {
return false
}
}
function startCliPreload(url: string) { function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url pendingCliUrl = url
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
showLoadingScreen(true) showLoadingScreen(true)
} }
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
// consuming the token in the hidden view and then failing in the main window.
if (isBootstrapTokenUrl(url)) {
finalizeCliSwap(url)
return
}
const view = new BrowserView({ const view = new BrowserView({
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
} }
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
if (!raw) return null
const first = raw.split(";")[0] ?? ""
const index = first.indexOf("=")
if (index < 0) return null
const key = first.slice(0, index).trim()
const value = first.slice(index + 1).trim()
if (key !== name || !value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
const transport = target.protocol === "https:" ? https : http
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
const req = transport.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
res.resume()
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
},
)
req.on("error", reject)
req.write(body)
req.end()
})
if (result.statusCode !== 200) {
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
value: sessionId,
httpOnly: true,
path: "/",
sameSite: "lax",
})
return true
}
async function startCli() { async function startCli() {
try { try {
@@ -323,11 +411,53 @@ async function startCli() {
} }
} }
async function maybeExchangeAndNavigate(baseUrl: string) {
if (bootstrapExchangeInFlight) {
return
}
const token = pendingBootstrapToken
if (!token) {
startCliPreload(baseUrl)
return
}
bootstrapExchangeInFlight = true
try {
const ok = await exchangeBootstrapToken(baseUrl, token)
pendingBootstrapToken = null
if (!ok) {
startCliPreload(`${baseUrl}/login`)
return
}
startCliPreload(baseUrl)
} catch (error) {
console.error("[cli] bootstrap token exchange failed:", error)
pendingBootstrapToken = null
startCliPreload(`${baseUrl}/login`)
} finally {
bootstrapExchangeInFlight = false
}
}
cliManager.on("bootstrapToken", (token) => {
pendingBootstrapToken = token
const status = cliManager.getStatus()
if (status.url) {
void maybeExchangeAndNavigate(status.url)
}
})
cliManager.on("ready", (status) => { cliManager.on("ready", (status) => {
if (!status.url) { if (!status.url) {
return return
} }
startCliPreload(status.url)
void maybeExchangeAndNavigate(status.url)
}) })
cliManager.on("status", (status) => { cliManager.on("status", (status) => {

View File

@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
type CliState = "starting" | "ready" | "error" | "stopped" type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all" type ListeningMode = "local" | "all"
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
export declare interface CliProcessManager { export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "bootstrapToken", listener: (token: string) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this on(event: "error", listener: (error: Error) => void): this
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" } private status: CliStatus = { state: "stopped" }
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private bootstrapToken: string | null = null
async start(options: StartOptions): Promise<CliStatus> { async start(options: StartOptions): Promise<CliStatus> {
if (this.child) { if (this.child) {
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = "" this.stdoutBuffer = ""
this.stderrBuffer = "" this.stderrBuffer = ""
this.bootstrapToken = null
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options) const cliEntry = this.resolveCliEntry(options)
@@ -173,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
return new Promise((resolve) => { return new Promise((resolve) => {
const killTimeout = setTimeout(() => { const killTimeout = setTimeout(() => {
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
child.kill("SIGKILL") child.kill("SIGKILL")
}, 4000) }, 30000)
child.on("exit", () => { child.on("exit", () => {
clearTimeout(killTimeout) clearTimeout(killTimeout)
@@ -227,11 +234,22 @@ export class CliProcessManager extends EventEmitter {
} }
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue const trimmed = line.trim()
console.info(`[cli][${stream}] ${line}`) if (!trimmed) continue
this.emit("log", { stream, message: line })
const port = this.extractPort(line) if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) {
this.bootstrapToken = token
this.emit("bootstrapToken", token)
}
continue
}
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") { if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}` const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`) console.info(`[cli] ready on ${url}`)
@@ -271,7 +289,7 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCliArgs(options: StartOptions, host: string): string[] { private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"] const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
if (options.dev) { if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
@@ -361,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.") 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", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.6.0", "version": "0.9.1",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",

View File

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

View File

@@ -1,5 +1,6 @@
import path from "path" import path from "path"
import { tool } from "@opencode-ai/plugin/tool" import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
type BackgroundProcess = { type BackgroundProcess = {
id: string id: string
@@ -12,11 +13,6 @@ type BackgroundProcess = {
outputSizeBytes?: number outputSizeBytes?: number
} }
type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
type BackgroundProcessOptions = { type BackgroundProcessOptions = {
baseDir: string baseDir: string
} }
@@ -27,30 +23,10 @@ type ParsedCommand = {
} }
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) { export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const requester = createCodeNomadRequester(config)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => { const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
return requester.requestJson<T>(`/background-processes${path}`, init)
const base = config.baseUrl.replace(/\/+$/, "")
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined) {
headers["Content-Type"] = "application/json"
}
const response = await fetch(url, {
...init,
headers,
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
} }
return { return {
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
if (char === "|" || char === "&" || char === ";") { if (char === "|" || char === "&" || char === ";") {
flush() flush()
const next = input[index + 1] tokens.push(char)
if ((char === "|" || char === "&") && next === char) {
tokens.push(char + next)
index += 1
} else {
tokens.push(char)
}
continue continue
} }
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
return tokens return tokens
} }
function isSeparator(token: string) { function isSeparator(token: string): boolean {
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&" return token === "|" || token === "&" || token === ";"
} }
function unquote(value: string) { function unquote(token: string): string {
if (value.length >= 2) { if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
const first = value[0] return token.slice(1, -1)
const last = value[value.length - 1]
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
return value.slice(1, -1)
}
} }
return value return token
} }
function isWithinBase(baseDir: string, target: string) { function isWithinBase(base: string, candidate: string): boolean {
const relative = path.relative(baseDir, target) const relative = path.relative(base, candidate)
if (!relative) return true return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
return !relative.startsWith("..") && !path.isAbsolute(relative)
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
} }

View File

@@ -1,74 +1,41 @@
export type PluginEvent = { import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = { export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadClient(config: CodeNomadConfig) { export function createCodeNomadClient(config: CodeNomadConfig) {
return { const requester = createCodeNomadRequester(config)
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
}
}
function requireEnv(key: string): string { return {
const value = process.env[key] postEvent: (event: PluginEvent) =>
if (!value || !value.trim()) { requester.requestVoid("/event", {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`) method: "POST",
body: JSON.stringify(event),
}),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
} }
return value
} }
function delay(ms: number) { function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)) return new Promise<void>((resolve) => setTimeout(resolve, ms))
} }
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) { async function startPluginEvents(
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event` requester: ReturnType<typeof createCodeNomadRequester>,
const response = await fetch(url, { onEvent: (event: PluginEvent) => void,
method: "POST", ) {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(event),
})
if (!response.ok) {
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
}
}
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
// Fail plugin startup if we cannot establish the initial connection. // Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(url, 3) const initialBody = await connectWithRetries(requester, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures. // After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(url, onEvent, initialBody) void consumeWithReconnect(requester, onEvent, initialBody)
} }
async function connectWithRetries(url: string, maxAttempts: number) { async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
let lastError: unknown let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try { try {
const response = await fetch(url, { headers: { Accept: "text/event-stream" } }) return await requester.requestSseBody("/events")
if (!response.ok || !response.body) {
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
}
return response.body
} catch (error) { } catch (error) {
lastError = error lastError = error
await delay(500 * attempt) await delay(500 * attempt)
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
} }
const reason = lastError instanceof Error ? lastError.message : String(lastError) const reason = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`) const url = requester.buildUrl("/events")
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
} }
async function consumeWithReconnect( async function consumeWithReconnect(
url: string, requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void, onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>, initialBody: ReadableStream<Uint8Array>,
) { ) {
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
while (true) { while (true) {
try { try {
if (!body) { if (!body) {
body = await connectWithRetries(url, 3) body = await connectWithRetries(requester, 3)
} }
await consumeSseBody(body, onEvent) await consumeSseBody(body, onEvent)

View File

@@ -0,0 +1,124 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${pluginBase}${normalized}`
}
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
const output: Record<string, string> = normalizeHeaders(headers)
output.Authorization = authorization
if (hasBody) {
output["Content-Type"] = output["Content-Type"] ?? "application/json"
}
return output
}
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
const url = buildUrl(path)
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
}
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable (${response.status})`)
}
return response.body as ReadableStream<Uint8Array>
}
return {
buildUrl,
fetch: fetchWithAuth,
requestJson,
requestVoid,
requestSseBody,
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -1,20 +1,30 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.6.0", "version": "0.9.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.6.0", "version": "0.9.1",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"bin": {
"codenomad": "dist/bin.js"
},
"devDependencies": { "devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"
@@ -475,6 +485,15 @@
"node": ">=18" "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": { "node_modules/@fastify/ajv-compiler": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
@@ -486,6 +505,15 @@
"fast-uri": "^2.0.0" "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": { "node_modules/@fastify/cors": {
"version": "8.5.0", "version": "8.5.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
@@ -520,6 +548,77 @@
"fast-deep-equal": "^3.1.3" "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": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -548,12 +647,31 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -593,6 +711,16 @@
"undici-types": "~7.16.0" "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": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -674,6 +802,30 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -700,6 +852,48 @@
"fastq": "^1.17.1" "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": { "node_modules/commander": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -709,6 +903,18 @@
"node": ">=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": { "node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -725,6 +931,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/diff": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -735,6 +983,27 @@
"node": ">=0.3.1" "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": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -777,6 +1046,12 @@
"@esbuild/win32-x64": "0.25.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": { "node_modules/fast-content-type-parse": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", "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" "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": { "node_modules/find-my-way": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
@@ -905,6 +1189,22 @@
"node": ">=14" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "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": "^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": { "node_modules/get-tsconfig": {
"version": "4.13.0", "version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "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" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -951,6 +1299,36 @@
"node": ">= 0.10" "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": { "node_modules/json-schema-ref-resolver": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", "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" "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": { "node_modules/make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -984,6 +1368,42 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/mnemonist": {
"version": "0.39.6", "version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
@@ -1008,6 +1428,52 @@
"node": ">=14.0.0" "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": { "node_modules/pino": {
"version": "9.14.0", "version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -1139,6 +1605,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "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": { "node_modules/safe-regex2": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", "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==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "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": { "node_modules/sonic-boom": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
@@ -1199,6 +1724,111 @@
"node": ">= 10.x" "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": { "node_modules/thread-stream": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
@@ -1217,6 +1847,15 @@
"node": ">=12" "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": { "node_modules/ts-node": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -1296,6 +1935,15 @@
"node": ">=14.17" "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": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -1310,6 +1958,128 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.6.0", "version": "0.9.1",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",
@@ -16,11 +16,11 @@
"codenomad": "dist/bin.js" "codenomad": "dist/bin.js"
}, },
"scripts": { "scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui", "build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs", "prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs", "prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
@@ -32,9 +32,11 @@
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
if (!existsSync(sourceDir)) {
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
metadata: FileSystemListingMetadata 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 const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse { export interface WorkspaceFileResponse {
@@ -167,7 +187,6 @@ export type WorkspaceEventType =
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event" | "instance.event"
| "instance.eventStatus" | "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload = export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -180,7 +199,6 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress { export interface NetworkAddress {
ip: string ip: string
@@ -198,6 +216,19 @@ export interface LatestReleaseInfo {
notes?: string 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 { export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */ /** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string httpBaseUrl: string
@@ -215,8 +246,9 @@ export interface ServerMeta {
workspaceRoot: string workspaceRoot: string
/** Reachable addresses for this server, external first. */ /** Reachable addresses for this server, external first. */
addresses: NetworkAddress[] addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */ serverVersion?: string
latestRelease?: LatestReleaseInfo ui?: UiMeta
support?: SupportMeta
} }
export type BackgroundProcessStatus = "running" | "stopped" | "error" export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -0,0 +1,175 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
username: string
password: PasswordHashRecord
userProvided: boolean
updatedAt: string
}
export interface AuthStatus {
username: string
passwordUserProvided: boolean
}
export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
getAuthFilePath() {
return this.authFilePath
}
load(): AuthFile | null {
if (this.overrideAuth) {
return this.overrideAuth
}
if (this.cachedFile) {
return this.cachedFile
}
try {
if (!fs.existsSync(this.authFilePath)) {
return null
}
const raw = fs.readFileSync(this.authFilePath, "utf-8")
const parsed = JSON.parse(raw) as AuthFile
if (!parsed || parsed.version !== 1) {
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
return null
}
this.cachedFile = parsed
return parsed
} catch (error) {
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
return null
}
}
ensureInitialized(params: {
username: string
password?: string
allowBootstrapWithoutPassword: boolean
}): void {
const password = params.password?.trim()
if (password) {
const now = new Date().toISOString()
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
userProvided: true,
updatedAt: now,
}
this.overrideAuth = runtime
this.cachedFile = null
this.bootstrapUsername = null
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
return
}
const existing = this.load()
if (existing) {
if (existing.username !== params.username) {
// Keep existing username unless explicitly overridden later.
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
}
this.bootstrapUsername = null
return
}
if (params.allowBootstrapWithoutPassword) {
this.bootstrapUsername = params.username
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
return
}
throw new Error(
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
)
}
validateCredentials(username: string, password: string): boolean {
const auth = this.load()
if (!auth) {
return false
}
if (username !== auth.username) {
return false
}
return verifyPassword(password, auth.password)
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
)
}
const current = this.load()
if (!current) {
if (!this.bootstrapUsername) {
throw new Error("Auth is not initialized")
}
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(created)
this.bootstrapUsername = null
return { username: created.username, passwordUserProvided: created.userProvided }
}
const next: AuthFile = {
...current,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(next)
return { username: next.username, passwordUserProvided: next.userProvided }
}
getStatus(): AuthStatus {
const current = this.load()
if (current) {
return { username: current.username, passwordUserProvided: current.userProvided }
}
if (this.bootstrapUsername) {
return { username: this.bootstrapUsername, passwordUserProvided: false }
}
throw new Error("Auth is not initialized")
}
private persist(auth: AuthFile) {
try {
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
this.cachedFile = auth
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
} catch (error) {
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
throw error
}
}
}

View File

@@ -0,0 +1,38 @@
import type { FastifyReply, FastifyRequest } from "fastify"
export function parseCookies(header: string | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!header) return result
const parts = header.split(";")
for (const part of parts) {
const index = part.indexOf("=")
if (index < 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (!key) continue
result[key] = decodeURIComponent(value)
}
return result
}
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
if (remoteAddress === "::ffff:127.0.0.1") return true
return false
}
export function wantsHtml(request: FastifyRequest): boolean {
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
return accept.includes("text/html") || accept.includes("application/xhtml")
}
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
reply.redirect("/login")
return
}
reply.code(401).send({ error: "Unauthorized" })
}

View File

@@ -0,0 +1,113 @@
import type { FastifyReply, FastifyRequest } from "fastify"
import path from "path"
import type { Logger } from "../logger"
import { AuthStore } from "./auth-store"
import { TokenManager } from "./token-manager"
import { SessionManager } from "./session-manager"
import { isLoopbackAddress, parseCookies } from "./http-auth"
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
export interface AuthManagerInit {
configPath: string
username: string
password?: string
generateToken: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
this.authStore.ensureInitialized({
username: init.username,
password: init.password,
allowBootstrapWithoutPassword: init.generateToken,
})
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
getCookieName(): string {
return this.cookieName
}
isTokenBootstrapEnabled(): boolean {
return Boolean(this.tokenManager)
}
issueBootstrapToken(): string | null {
if (!this.tokenManager) return null
return this.tokenManager.generate()
}
consumeBootstrapToken(token: string): boolean {
if (!this.tokenManager) return false
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
}
createSession(username: string) {
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
return isLoopbackAddress(request.socket.remoteAddress)
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
return { username: session.username, sessionId: session.id }
}
setSessionCookie(reply: FastifyReply, sessionId: string) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}
return parts.join("; ")
}

View File

@@ -0,0 +1,49 @@
import crypto from "crypto"
export interface PasswordHashRecord {
algorithm: "scrypt"
saltBase64: string
hashBase64: string
keyLength: number
params: {
N: number
r: number
p: number
maxmem: number
}
}
const DEFAULT_SCRYPT_PARAMS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
hashBase64: Buffer.from(derived).toString("base64"),
keyLength,
params,
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}

View File

@@ -0,0 +1,23 @@
import crypto from "crypto"
export interface SessionInfo {
id: string
createdAt: number
username: string
}
export class SessionManager {
private sessions = new Map<string, SessionInfo>()
createSession(username: string): SessionInfo {
const id = crypto.randomBytes(32).toString("base64url")
const info: SessionInfo = { id, createdAt: Date.now(), username }
this.sessions.set(id, info)
return info
}
getSession(id: string | undefined): SessionInfo | undefined {
if (!id) return undefined
return this.sessions.get(id)
}
}

View File

@@ -0,0 +1,32 @@
import crypto from "crypto"
export interface BootstrapToken {
token: string
createdAt: number
consumed: boolean
}
export class TokenManager {
private token: BootstrapToken | null = null
constructor(private readonly ttlMs: number) {}
generate(): string {
const token = crypto.randomBytes(32).toString("base64url")
this.token = { token, createdAt: Date.now(), consumed: false }
return token
}
consume(token: string): boolean {
if (!this.token) return false
if (this.token.consumed) return false
if (Date.now() - this.token.createdAt > this.ttlMs) return false
if (token !== this.token.token) return false
this.token.consumed = true
return true
}
peek(): string | null {
return this.token?.token ?? null
}
}

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 { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path" import path from "path"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
@@ -60,10 +60,13 @@ export class BackgroundProcessManager {
const outputStream = createWriteStream(outputPath, { flags: "a" }) 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, cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32", detached: process.platform !== "win32",
...spawnOptions,
}) })
child.on("exit", () => { child.on("exit", () => {
@@ -274,7 +277,15 @@ export class BackgroundProcessManager {
const pid = child.pid const pid = child.pid
if (!pid) return 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 { try {
process.kill(-pid, signal) process.kill(-pid, signal)
return 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 { private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped" if (code === null) return "stopped"
if (code === 0) return "stopped" if (code === 0) return "stopped"

View File

@@ -4,10 +4,12 @@ import {
BinaryUpdateRequest, BinaryUpdateRequest,
BinaryValidationResult, BinaryValidationResult,
} from "../api-types" } from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store" import { ConfigStore } from "./store"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema" import type { ConfigFile } from "./schema"
import { Logger } from "../logger" import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry { export class BinaryRegistry {
constructor( constructor(
@@ -135,8 +137,42 @@ export class BinaryRegistry {
} }
private validateRecord(record: BinaryRecord): BinaryValidationResult { private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check. const inputPath = record.path
return { valid: true, version: record.version } 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 { private buildFallbackRecord(path: string): BinaryRecord {

View File

@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]), modelRecents: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: 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.dataChanged", handler)
this.on("instance.event", handler) this.on("instance.event", handler)
this.on("instance.eventStatus", handler) this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => { return () => {
this.off("workspace.created", handler) this.off("workspace.created", handler)
this.off("workspace.started", handler) this.off("workspace.started", handler)
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)
this.off("instance.event", handler) this.off("instance.event", handler)
this.off("instance.eventStatus", 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 os from "os"
import path from "path" import path from "path"
import { import {
FileSystemCreateFolderResponse,
FileSystemEntry, FileSystemEntry,
FileSystemListResponse, FileSystemListResponse,
FileSystemListingMetadata, FileSystemListingMetadata,
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
return this.listRestrictedWithMetadata(targetPath, includeFiles) 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 { readFile(relativePath: string): string {
if (this.unrestricted) { if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode") throw new Error("readFile is not available in unrestricted mode")
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
return { entries, metadata } 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[] { private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true }) const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = [] const results: FileSystemEntry[] = []

View File

@@ -17,7 +17,8 @@ import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events" import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" 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) const require = createRequire(import.meta.url)
@@ -36,7 +37,13 @@ interface CliOptions {
logDestination?: string logDestination?: string
uiStaticDir: string uiStaticDir: string
uiDevServer?: string uiDevServer?: string
uiAutoUpdate: boolean
uiNoUpdate: boolean
uiManifestUrl?: string
launch: boolean launch: boolean
authUsername: string
authPassword?: string
generateToken: boolean
} }
const DEFAULT_PORT = 9898 const DEFAULT_PORT = 9898
@@ -62,7 +69,21 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR), 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-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("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
.env("CODENOMAD_SERVER_USERNAME")
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
program.parse(argv, { from: "user" }) program.parse(argv, { from: "user" })
const parsed = program.opts<{ const parsed = program.opts<{
@@ -76,13 +97,22 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string logDestination?: string
uiDir: string uiDir: string
uiDevServer?: string uiDevServer?: string
uiNoUpdate?: boolean
uiAutoUpdate?: string
uiManifestUrl?: string
launch?: boolean launch?: boolean
username: string
password?: string
generateToken?: boolean
}>() }>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host) const normalizedHost = resolveHost(parsed.host)
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
return { return {
port: parsed.port, port: parsed.port,
host: normalizedHost, host: normalizedHost,
@@ -93,7 +123,13 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination, logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir, uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer, uiDevServer: parsed.uiDevServer,
uiAutoUpdate,
uiNoUpdate: Boolean(parsed.uiNoUpdate),
uiManifestUrl: parsed.uiManifestUrl,
launch: Boolean(parsed.launch), launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
} }
} }
@@ -106,10 +142,22 @@ function parsePort(input: string): number {
} }
function resolveHost(input: string | undefined): string { 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 "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() { async function main() {
@@ -119,21 +167,45 @@ async function main() {
const configLogger = logger.child({ component: "config" }) const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" }) const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server") const logOptions = {
...options,
authPassword: options.authPassword ? "[REDACTED]" : undefined,
}
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger) const eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`, httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`, eventsUrl: `/api/events`,
host: options.host, host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local", listeningMode: isLoopbackHost(options.host) ? "local" : "all",
port: options.port, port: options.port,
hostLabel: options.host, hostLabel: options.host,
workspaceRoot: options.rootDir, workspaceRoot: options.rootDir,
addresses: [], addresses: [],
} }
const authManager = new AuthManager(
{
configPath: options.configPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
},
logger.child({ component: "auth" }),
)
if (options.generateToken) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const configStore = new ConfigStore(options.configPath, eventBus, configLogger) const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
@@ -152,19 +224,36 @@ async function main() {
logger: logger.child({ component: "instance-events" }), logger: logger.child({ component: "instance-events" }),
}) })
const releaseMonitor = startReleaseMonitor({ const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
currentVersion: packageJson.version, const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
logger: logger.child({ component: "release-monitor" }), const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
onUpdate: (release) => { const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
if (release) {
serverMeta.latestRelease = release const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
eventBus.publish({ type: "app.releaseAvailable", release })
} else { const uiResolution = await resolveUi({
delete serverMeta.latestRelease 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({ const server = createHttpServer({
host: options.host, host: options.host,
port: options.port, port: options.port,
@@ -175,8 +264,9 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
uiStaticDir: options.uiStaticDir, authManager,
uiDevServerUrl: options.uiDevServer, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger, logger,
}) })
@@ -196,23 +286,35 @@ async function main() {
return return
} }
shuttingDown = true shuttingDown = true
logger.info("Received shutdown signal, closing server") logger.info("Received shutdown signal, stopping workspaces and server")
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
try { const shutdownWorkspaces = (async () => {
instanceEventBridge.shutdown() try {
await workspaceManager.shutdown() instanceEventBridge.shutdown()
logger.info("Workspace manager shutdown complete") } catch (error) {
} catch (error) { logger.warn({ err: error }, "Instance event bridge shutdown failed")
logger.error({ err: error }, "Workspace manager 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") logger.info("Exiting process")
process.exit(0) process.exit(0)

View File

@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps { interface HttpServerDeps {
host: string host: string
@@ -34,6 +37,7 @@ interface HttpServerDeps {
eventBus: EventBus eventBus: EventBus
serverMeta: ServerMeta serverMeta: ServerMeta
instanceStore: InstanceStore instanceStore: InstanceStore
authManager: AuthManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
logger: Logger logger: Logger
@@ -88,8 +92,42 @@ export function createHttpServer(deps: HttpServerDeps) {
done() done()
}) })
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
app.register(cors, { app.register(cors, {
origin: true, origin: (origin, cb) => {
if (!origin) {
cb(null, true)
return
}
let selfOrigin: string | null = null
try {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
cb(null, true)
return
}
cb(null, false)
},
credentials: true, credentials: true,
}) })
@@ -109,6 +147,76 @@ export function createHttpServer(deps: HttpServerDeps) {
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
const publicPagePaths = new Set(["/login"])
if (deps.authManager.isTokenBootstrapEnabled()) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
if (pluginMatch) {
const workspaceId = pluginMatch[1]
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
const provided = Array.isArray(request.headers.authorization)
? request.headers.authorization[0]
: request.headers.authorization
if (expected && provided && provided === expected) {
done()
return
}
}
sendUnauthorized(request, reply)
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
done()
})
app.get("/", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.redirect("/login")
return
}
if (deps.uiDevServerUrl) {
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
return
}
const uiDir = deps.uiStaticDir
const indexPath = path.join(uiDir, "index.html")
if (uiDir && fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
return
}
reply.code(404).send({ message: "UI bundle missing" })
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
@@ -125,9 +233,9 @@ export function createHttpServer(deps: HttpServerDeps) {
if (deps.uiDevServerUrl) { if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl) setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
} else { } else {
setupStaticUi(app, deps.uiStaticDir) setupStaticUi(app, deps.uiStaticDir, deps.authManager)
} }
return { return {
@@ -175,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
} }
} }
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}` const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort 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") deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`) console.log(`CodeNomad Server is ready at ${serverUrl}`)
@@ -260,6 +368,7 @@ async function proxyWorkspaceRequest(args: {
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance") logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) { if (logger.isLevelEnabled("trace")) {
@@ -267,6 +376,12 @@ async function proxyWorkspaceRequest(args: {
} }
return reply.from(targetUrl, { return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
return headers
},
onError: (proxyReply, { error }) => { onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) { if (!proxyReply.sent) {
@@ -284,7 +399,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}` return trimmed.length === 0 ? "/" : `/${trimmed}`
} }
function setupStaticUi(app: FastifyInstance, uiDir: string) { function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) { if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only") app.log.warn("UI static directory not provided; API endpoints only")
return return
@@ -310,6 +425,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
return return
} }
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8")) reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else { } else {
@@ -318,7 +439,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
}) })
} }
function setupDevProxy(app: FastifyInstance, upstreamBase: string) { function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server") app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? "" const url = request.raw.url ?? ""
@@ -326,6 +447,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
reply.code(404).send({ message: "Not Found" }) reply.code(404).send({ message: "Not Found" })
return return
} }
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
void proxyToDevServer(request, reply, upstreamBase) void proxyToDevServer(request, reply, upstreamBase)
}) })
} }

View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad Login</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0 0 18px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
label {
display: block;
font-size: 12px;
margin: 10px 0 6px;
color: rgba(255, 255, 255, 0.75);
}
input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0f0f16;
color: #fff;
}
button {
width: 100%;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: #4c6fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign in</h1>
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" />
<button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const errorEl = $("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
const hideError = () => {
errorEl.textContent = ""
errorEl.style.display = "none"
}
async function submit() {
hideError()
const username = $("username").value.trim()
const password = $("password").value
if (!username || !password) {
showError("Username and password are required.")
return
}
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
$("submit").addEventListener("click", submit)
$("password").addEventListener("keydown", (e) => {
if (e.key === "Enter") submit()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Connecting…</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const token = (location.hash || "").replace(/^#/, "").trim()
const errorEl = document.getElementById("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Token exchange failed (${res.status})`)
return
}
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
run()
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from "fastify"
import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
interface RouteDeps {
authManager: AuthManager
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const TokenSchema = z.object({
token: z.string().min(1),
})
const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
}
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
app.get("/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.type("text/html").send(getTokenHtml())
})
app.get("/api/auth/status", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.send({ authenticated: false })
return
}
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
})
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = TokenSchema.parse(request.body ?? {})
const ok = deps.authManager.consumeBootstrapToken(body.token)
if (!ok) {
reply.code(401).send({ error: "Invalid token" })
return
}
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (_request, reply) => {
deps.authManager.clearSessionCookie(reply)
reply.send({ ok: true })
})
app.post("/api/auth/password", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.code(401).send({ error: "Unauthorized" })
return
}
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reply.code(409).type("text/plain").send(message)
}
})
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
default:
return char
}
})
}

View File

@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
includeFiles: z.coerce.boolean().optional(), includeFiles: z.coerce.boolean().optional(),
}) })
const FilesystemCreateFolderSchema = z.object({
parentPath: z.string().optional(),
name: z.string(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => { app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {}) const query = FilesystemQuerySchema.parse(request.query ?? {})
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
return { error: (error as Error).message } 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 { return {
...meta, ...meta,
port, port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local", listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses, 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[] { function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces() const interfaces = os.networkInterfaces()
const seen = new Set<string>() 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

@@ -96,8 +96,15 @@ export class InstanceEventBridge {
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event` const url = `http://${INSTANCE_HOST}:${port}/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: "text/event-stream" }, headers,
signal, signal,
dispatcher: STREAM_AGENT, dispatcher: STREAM_AGENT,
}) })

View File

@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js" import { getOpencodeConfigDir } from "../opencode-config.js"
import {
buildOpencodeBasicAuthHeader,
DEFAULT_OPENCODE_USERNAME,
generateOpencodeServerPassword,
OPENCODE_SERVER_PASSWORD_ENV,
OPENCODE_SERVER_USERNAME_ENV,
} from "./opencode-auth"
const STARTUP_STABILITY_DELAY_MS = 1500 const STARTUP_STABILITY_DELAY_MS = 1500
@@ -29,6 +36,7 @@ export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>() private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string private readonly opencodeConfigDir: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) { constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
@@ -47,6 +55,10 @@ export class WorkspaceManager {
return this.workspaces.get(id)?.port return this.workspaces.get(id)?.port
} }
getInstanceAuthorizationHeader(id: string): string | undefined {
return this.opencodeAuth.get(id)?.authorization
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId) const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path }) const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -106,11 +118,22 @@ export class WorkspaceManager {
const preferences = this.options.configStore.get().preferences ?? {} const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {} const userEnvironment = preferences.environmentVariables ?? {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
if (!authorization) {
throw new Error("Failed to build OpenCode auth header")
}
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
const environment = { const environment = {
...userEnvironment, ...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir, OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id, CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(), CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
} }
try { try {
@@ -154,6 +177,7 @@ export class WorkspaceManager {
} }
this.workspaces.delete(id) this.workspaces.delete(id)
this.opencodeAuth.delete(id)
clearWorkspaceSearchCache(workspace.path) clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) { if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
@@ -163,17 +187,29 @@ export class WorkspaceManager {
async shutdown() { async shutdown() {
this.options.logger.info("Shutting down all workspaces") this.options.logger.info("Shutting down all workspaces")
const stopTasks: Array<Promise<void>> = []
for (const [id, workspace] of this.workspaces) { for (const [id, workspace] of this.workspaces) {
if (workspace.pid) { 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 {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped") 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.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared") this.options.logger.info("All workspaces cleared")
} }
@@ -200,13 +236,15 @@ export class WorkspaceManager {
try { try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" }) const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) { if (result.status === 0 && result.stdout) {
const resolved = result.stdout const candidates = result.stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.find((line) => line.length > 0) .filter((line) => line.length > 0)
.filter((line) => !/^INFO:/i.test(line))
if (resolved) { if (candidates.length > 0) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH") const resolved = this.pickBinaryCandidate(candidates)
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
return resolved return resolved
} }
} else if (result.error) { } else if (result.error) {
@@ -219,6 +257,23 @@ export class WorkspaceManager {
return identifier 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 { private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) { if (!resolvedPath) {
return undefined return undefined
@@ -317,7 +372,13 @@ export class WorkspaceManager {
const url = `http://127.0.0.1:${port}/project/current` const url = `http://127.0.0.1:${port}/project/current`
try { try {
const response = await fetch(url) const headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}` const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
@@ -408,6 +469,8 @@ export class WorkspaceManager {
const workspace = this.workspaces.get(workspaceId) const workspace = this.workspaces.get(workspaceId)
if (!workspace) return if (!workspace) return
this.opencodeAuth.delete(workspaceId)
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited") this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined workspace.pid = undefined

View File

@@ -0,0 +1,22 @@
import crypto from "node:crypto"
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
export function generateOpencodeServerPassword(): string {
return crypto.randomBytes(32).toString("base64url")
}
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
const username = params.username
const password = params.password
if (!username || !password) {
return undefined
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

View File

@@ -1,10 +1,59 @@
import { ChildProcess, spawn } from "child_process" import { ChildProcess, spawn, spawnSync } from "child_process"
import { existsSync, statSync } from "fs" import { existsSync, statSync } from "fs"
import path from "path" import path from "path"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types" import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger" import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
redacted[key] = value
continue
}
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
}
return redacted
}
interface LaunchOptions { interface LaunchOptions {
workspaceId: string workspaceId: string
folder: string folder: string
@@ -59,22 +108,27 @@ export class WorkspaceRuntime {
} }
return new Promise((resolve, reject) => { 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( this.logger.info(
{ {
workspaceId: options.workspaceId, workspaceId: options.workspaceId,
folder: options.folder, folder: options.folder,
binary: options.binaryPath, binary: options.binaryPath,
args, spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine, commandLine,
env, env: redactEnvironment(env),
}, },
"Launching OpenCode process", "Launching OpenCode process",
) )
const child = spawn(options.binaryPath, args, { const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder, cwd: options.folder,
env, env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
}) })
const managed: ManagedProcess = { child, requestedStop: false } const managed: ManagedProcess = { child, requestedStop: false }
@@ -207,10 +261,96 @@ export class WorkspaceRuntime {
const child = managed.child const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process") 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) => { await new Promise<void>((resolve, reject) => {
let escalationTimer: NodeJS.Timeout | null = null
const cleanup = () => { const cleanup = () => {
child.removeListener("exit", onExit) child.removeListener("exit", onExit)
child.removeListener("error", onError) child.removeListener("error", onError)
if (escalationTimer) {
clearTimeout(escalationTimer)
escalationTimer = null
}
} }
const onExit = () => { const onExit = () => {
@@ -222,32 +362,30 @@ export class WorkspaceRuntime {
reject(error) reject(error)
} }
const resolveIfAlreadyExited = () => { if (isAlreadyExited()) {
if (child.exitCode !== null || child.signalCode !== null) { this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") cleanup()
cleanup() resolve()
resolve() return
return true
}
return false
} }
child.once("exit", onExit) child.once("exit", onExit)
child.once("error", onError) child.once("error", onError)
if (resolveIfAlreadyExited()) { this.logger.debug(
return { workspaceId, pid, detached: process.platform !== "win32" },
} "Sending SIGTERM to workspace process (tree/group)",
)
sendStopSignal("SIGTERM")
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process") escalationTimer = setTimeout(() => {
child.kill("SIGTERM") escalationTimer = null
setTimeout(() => { if (isAlreadyExited()) {
if (!child.killed) { this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing") return
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
} }
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
sendStopSignal("SIGKILL")
}, 2000) }, 2000)
}) })
} }

View File

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

View File

@@ -166,6 +166,44 @@ function copyServerArtifacts() {
} }
} }
function stripNodeModuleBins() {
const root = path.join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() { function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html") const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets") const assetsSource = path.join(uiDist, "assets")
@@ -192,4 +230,5 @@ ensureServerDependencies()
ensureServerBuild() ensureServerBuild()
ensureUiBuild() ensureUiBuild()
copyServerArtifacts() copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets() copyUiLoadingAssets()

View File

@@ -7,14 +7,15 @@ use std::collections::VecDeque;
use std::env; use std::env;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
@@ -31,9 +32,17 @@ fn workspace_root() -> Option<PathBuf> {
}) })
} }
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
fn navigate_main(app: &AppHandle, url: &str) { fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") { if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}")); let mut display = url.to_string();
if let Some(hash_index) = display.find('#') {
display.replace_range(hash_index + 1.., "[REDACTED]");
}
log_line(&format!("navigating main to {display}"));
if let Ok(parsed) = Url::parse(url) { if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed); let _ = win.navigate(parsed);
} else { } else {
@@ -44,6 +53,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
} }
} }
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let cookie_kv = set_cookie.split(';').next()?.trim();
if !cookie_kv.starts_with(&prefix) {
return None;
}
let value = cookie_kv.trim_start_matches(&prefix).trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
// This is only used for local bootstrap; we assume plain HTTP.
let mut stream = TcpStream::connect((host, port))?;
let body = format!("{{\"token\":\"{}\"}}", token);
let request = format!(
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(request.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (raw_headers, _rest) = response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
.unwrap_or((response.as_str(), ""));
let mut lines = raw_headers.lines();
let status_line = lines.next().unwrap_or("");
if !status_line.contains(" 200 ") {
return Ok(None);
}
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
}
}
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.domain(domain)
.path("/")
.http_only(true)
.same_site(tauri::webview::cookie::SameSite::Lax)
.build();
if let Some(win) = app.webview_windows().get("main") {
win.set_cookie(cookie)?;
}
Ok(())
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -139,6 +227,7 @@ pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>, child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
} }
impl CliProcessManager { impl CliProcessManager {
@@ -147,6 +236,7 @@ impl CliProcessManager {
status: Arc::new(Mutex::new(CliStatus::default())), status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)), child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)), ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
} }
} }
@@ -154,6 +244,7 @@ impl CliProcessManager {
log_line(&format!("start requested (dev={dev})")); log_line(&format!("start requested (dev={dev})"));
self.stop()?; self.stop()?;
self.ready.store(false, Ordering::SeqCst); self.ready.store(false, Ordering::SeqCst);
*self.bootstrap_token.lock() = None;
{ {
let mut status = self.status.lock(); let mut status = self.status.lock();
status.state = CliState::Starting; status.state = CliState::Starting;
@@ -167,8 +258,9 @@ impl CliProcessManager {
let status_arc = self.status.clone(); let status_arc = self.status.clone();
let child_arc = self.child.clone(); let child_arc = self.child.clone();
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) { if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
log_line(&format!("cli spawn failed: {err}")); log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock(); let mut locked = status_arc.lock();
locked.state = CliState::Error; locked.state = CliState::Error;
@@ -186,6 +278,7 @@ impl CliProcessManager {
pub fn stop(&self) -> anyhow::Result<()> { pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock(); let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() { if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
libc::kill(child.id() as i32, libc::SIGTERM); libc::kill(child.id() as i32, libc::SIGTERM);
@@ -200,7 +293,12 @@ impl CliProcessManager {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { 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)] #[cfg(unix)]
unsafe { unsafe {
libc::kill(child.id() as i32, libc::SIGKILL); libc::kill(child.id() as i32, libc::SIGKILL);
@@ -237,6 +335,7 @@ impl CliProcessManager {
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>, child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool, dev: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
log_line("resolving CLI entry"); log_line("resolving CLI entry");
@@ -318,8 +417,10 @@ impl CliProcessManager {
let status_clone = status.clone(); let status_clone = status.clone();
let app_clone = app.clone(); let app_clone = app.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
.lock() .lock()
.as_mut() .as_mut()
@@ -332,10 +433,10 @@ impl CliProcessManager {
.map(BufReader::new); .map(BufReader::new);
if let Some(reader) = stdout { if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone); Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone); Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
} }
}); });
@@ -407,10 +508,12 @@ impl CliProcessManager {
app: &AppHandle, app: &AppHandle,
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok(); let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop { loop {
buffer.clear(); buffer.clear();
@@ -419,6 +522,17 @@ impl CliProcessManager {
Ok(_) => { Ok(_) => {
let line = buffer.trim_end(); let line = buffer.trim_end();
if !line.is_empty() { if !line.is_empty() {
if line.starts_with(token_prefix) {
let token = line.trim_start_matches(token_prefix).trim();
if !token.is_empty() {
let mut guard = bootstrap_token.lock();
if guard.is_none() {
*guard = Some(token.to_string());
}
}
continue;
}
log_line(&format!("[cli][{}] {}", stream, line)); log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) { if ready.load(Ordering::SeqCst) {
@@ -430,7 +544,7 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready(app, status, ready, port); Self::mark_ready(app, status, ready, bootstrap_token, port);
continue; continue;
} }
@@ -440,13 +554,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready(app, status, ready, port); Self::mark_ready(app, status, ready, bootstrap_token, port);
continue; continue;
} }
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) { if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16); Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
continue; continue;
} }
} }
@@ -458,16 +572,46 @@ impl CliProcessManager {
} }
} }
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) { fn mark_ready(
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
) {
ready.store(true, Ordering::SeqCst); ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let mut locked = status.lock(); let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port); locked.port = Some(port);
locked.url = Some(url.clone()); locked.url = Some(base_url.clone());
locked.state = CliState::Ready; locked.state = CliState::Ready;
locked.error = None; locked.error = None;
log_line(&format!("cli ready on {url}")); log_line(&format!("cli ready on {base_url}"));
navigate_main(app, &url);
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
navigate_main(app, &base_url);
}
let _ = app.emit("cli:ready", locked.clone()); let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked); Self::emit_status(app, &locked);
} }
@@ -551,6 +695,7 @@ impl CliEntry {
host.to_string(), host.to_string(),
"--port".to_string(), "--port".to_string(),
"0".to_string(), "0".to_string(),
"--generate-token".to_string(),
]; ];
if dev { if dev {
args.push("--ui-dev-server".to_string()); args.push("--ui-dev-server".to_string());

View File

@@ -163,7 +163,8 @@ fn main() {
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|app_handle, event| match event { .run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => { tauri::RunEvent::ExitRequested { api, .. } => {
api.prevent_exit();
let app = app_handle.clone(); let app = app_handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() { if let Some(state) = app.try_state::<AppState>() {
@@ -173,18 +174,18 @@ fn main() {
}); });
} }
tauri::RunEvent::WindowEvent { tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed, event: tauri::WindowEvent::CloseRequested { api, .. },
.. ..
} => { } => {
if app_handle.webview_windows().len() <= 1 { // Ensure we have time to stop the CLI process before the app exits.
let app = app_handle.clone(); api.prevent_close();
std::thread::spawn(move || { let app = app_handle.clone();
if let Some(state) = app.try_state::<AppState>() { std::thread::spawn(move || {
let _ = state.manager.stop(); if let Some(state) = app.try_state::<AppState>() {
} let _ = state.manager.stop();
app.exit(0); }
}); app.exit(0);
} });
} }
_ => {} _ => {}
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.6.0", "version": "0.9.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.1", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",

View File

@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay" import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown" import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme" import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
@@ -94,6 +95,7 @@ const App: Component = () => {
}) })
onMount(() => { onMount(() => {
void initGithubStars()
updateInstanceTabBarHeight() updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight() const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)

View File

@@ -4,6 +4,7 @@ import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -99,15 +100,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
data-agent-selector data-agent-selector
class="selector-trigger" class="selector-trigger"
> >
<Select.Value<Agent>> <div class="flex-1 min-w-0">
{(state) => ( <Select.Value<Agent>>
<div class="selector-trigger-label"> {(state) => (
<span class="selector-trigger-primary"> <div class="selector-trigger-label selector-trigger-label--stacked">
Agent: {state.selectedOption()?.name ?? "None"} <span class="selector-trigger-primary selector-trigger-primary--align-left">
</span> Agent: {state.selectedOption()?.name ?? "None"}
</div> </span>
)} </div>
</Select.Value> )}
</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"> <Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" /> <ChevronDown class="w-3 h-3" />
</Select.Icon> </Select.Icon>

View File

@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
const AlertDialog: Component = () => { const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined let primaryButtonRef: HTMLButtonElement | undefined
let promptInputRef: HTMLInputElement | undefined
createEffect(() => { createEffect(() => {
if (alertDialogState()) { const state = alertDialogState()
queueMicrotask(() => { if (!state) return
primaryButtonRef?.focus()
}) queueMicrotask(() => {
} if (state.type === "prompt") {
promptInputRef?.focus()
promptInputRef?.select()
return
}
primaryButtonRef?.focus()
})
}) })
return ( return (
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
</div> </div>
</div> </div>
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide"> <label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
{payload.inputLabel || "Arguments"} <input
</label> ref={(el) => {
<input promptInputRef = el
class="modal-search-input mt-2" }}
value={inputValue()} class="form-input mt-2"
placeholder={payload.inputPlaceholder || ""} value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)} placeholder={payload.inputPlaceholder || ""}
onKeyDown={(e) => { autocapitalize="off"
if (e.key === "Enter") { autocorrect="off"
e.preventDefault() spellcheck={false}
dismiss(true, payload, inputValue()) onInput={(e) => setInputValue(e.currentTarget.value)}
} onKeyDown={(e) => {
}} if (e.key === "Enter") {
/> e.preventDefault()
</div> dismiss(true, payload, inputValue())
</Show> }
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && ( {(isConfirm || isPrompt) && (

View File

@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
setLoading(false) setLoading(false)
}) })
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id)) eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) as { type?: string; content?: string } const payload = JSON.parse(event.data) as { type?: string; content?: string }

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 { 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 type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
function normalizePathKey(input?: string | null) { function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") { if (!input || input === "." || input === "./") {
@@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("") const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map()) const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set()) const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null) const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
props.onSelect(absolutePath) 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) { function isPathLoading(path: string) {
return loadingPaths().has(normalizePathKey(path)) 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-label">Current folder</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span> <span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div> </div>
<button <div class="directory-browser-current-actions">
type="button" <button
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select" type="button"
disabled={!canSelectCurrent()} class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
onClick={() => { disabled={!canSelectCurrent() || creatingFolder()}
const absolute = currentAbsolutePath() onClick={() => {
if (absolute) { const absolute = currentAbsolutePath()
props.onSelect(absolute) if (absolute) {
} props.onSelect(absolute)
}} }
> }}
Select Current >
</button> 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> </div>
</Show> </Show>
<Show <Show

View File

@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
onToggleExpand: (nextState: "normal" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
}
return (
<button
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label="Toggle chat input height"
>
<Show
when={props.expandState() === "normal"}
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
>
<Maximize2 class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
)
}

View File

@@ -1,10 +1,14 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" 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 { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" 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 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) { 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 normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n" const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [ const blockedKeys = [
@@ -174,6 +191,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isLoading()) return if (isLoading()) return
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
@@ -228,167 +250,228 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
style="background-color: var(--surface-secondary)" style="background-color: var(--surface-secondary)"
> >
<div <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"} aria-busy={isLoading() ? "true" : "false"}
> >
<Show when={props.onOpenRemoteAccess}> <Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6"> <div class="absolute top-4 right-6">
<button <button
type="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?.()} onClick={() => props.onOpenRemoteAccess?.()}
> >
<MonitorUp class="w-4 h-4" /> <MonitorUp class="w-4 h-4" />
</button> </button>
</div> </div>
</Show> </Show>
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" /> <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> </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>
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block"> <div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
<div class="panel-footer-hints"> <div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
<Show when={folders().length > 0}> {/* Right column: recent folders */}
<div class="flex items-center gap-1.5"> <div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<kbd class="kbd"></kbd> <Show
<kbd class="kbd"></kbd> when={folders().length > 0}
<span>Navigate</span> fallback={
</div> <div class="panel panel-empty-state flex-1">
<div class="flex items-center gap-1.5"> <div class="panel-empty-state-icon">
<kbd class="kbd">Enter</kbd> <Clock class="w-12 h-12 mx-auto" />
<span>Select</span> </div>
</div> <p class="panel-empty-state-title">No Recent Folders</p>
<div class="flex items-center gap-1.5"> <p class="panel-empty-state-description">Browse for a folder to get started</p>
<kbd class="kbd">Del</kbd> </div>
<span>Remove</span> }
>
<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> </div>
</Show> </Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" /> </div>
<span>Browse</span>
{/* 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> </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 { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list" import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint" import KeyboardHint from "../keyboard-hint"
import Kbd from "../kbd"
import InstanceWelcomeView from "../instance-welcome-view" import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view" import InfoView from "../info-view"
import InstanceServiceStatus from "../instance-service-status" import InstanceServiceStatus from "../instance-service-status"
import AgentSelector from "../agent-selector" import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector" import ModelSelector from "../model-selector"
import ThinkingSelector from "../thinking-selector"
import CommandPalette from "../command-palette" import CommandPalette from "../command-palette"
import PermissionNotificationBanner from "../permission-notification-banner" import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal" import PermissionApprovalModal from "../permission-approval-modal"
import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo" import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel" import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return true 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(() => { createEffect(() => {
const pending = pendingSidebarAction() const pending = pendingSidebarAction()
if (!pending) return if (!pending) return
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setPendingSidebarAction(null) setPendingSidebarAction(null)
return return
} }
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl() const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) { if (handled) {
setPendingSidebarAction(null) setPendingSidebarAction(null)
} }
@@ -875,7 +889,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-sidebar flex flex-col flex-1 min-h-0"> <div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList <SessionList
instanceId={props.instance.id} instanceId={props.instance.id}
sessions={allInstanceSessions()}
threads={sessionThreads()} threads={sessionThreads()}
activeSessionId={activeSessionIdForInstance()} activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect} onSelect={handleSessionSelect}
@@ -902,21 +915,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)} 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 <ModelSelector
instanceId={props.instance.id} instanceId={props.instance.id}
sessionId={activeSession().id} sessionId={activeSession().id}
currentModel={activeSession().model} currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)} onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/> />
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
</div> </div>
</> </>
)} )}

View File

@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid" import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
@@ -82,8 +82,20 @@ interface TaskSessionLocation {
parentId: string | null parentId: string | null
} }
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null { function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
if (!sessionId) return null if (!sessionId) return null
if (preferredInstanceId) {
const session = sessions().get(preferredInstanceId)?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId: preferredInstanceId,
parentId: session.parentId ?? null,
}
}
}
const allSessions = sessions() const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) { for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId) const session = sessionMap?.get(sessionId)
@@ -235,16 +247,11 @@ export default function MessageBlock(props: MessageBlockProps) {
const index = props.messageIndex const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex() const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number } // Intentionally untracked: messageInfoVersion updates should not trigger
const infoTimestamp = // a full message block rebuild; record revision is the invalidation key.
typeof infoTime.completed === "number" const info = untrack(messageInfo)
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
const cacheSignature = [ const cacheSignature = [
current.id, current.id,
current.revision, current.revision,
@@ -252,8 +259,6 @@ export default function MessageBlock(props: MessageBlockProps) {
props.showThinking() ? 1 : 0, props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0, props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|") ].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id) const cachedBlock = sessionCache.messageBlocks.get(current.id)
@@ -447,7 +452,7 @@ export default function MessageBlock(props: MessageBlockProps) {
const hasToolState = const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)) Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : "" const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => { const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session" import type { Model } from "../types/session"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import Kbd from "./kbd"
const log = getLogger("session") const log = getLogger("session")
@@ -105,7 +106,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
ref={triggerRef} ref={triggerRef}
class="selector-trigger" 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"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
Model: {currentModelValue()?.name ?? "None"} Model: {currentModelValue()?.name ?? "None"}
</span> </span>
@@ -115,6 +116,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
</span> </span>
)} )}
</div> </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"> <Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" /> <ChevronDown class="w-3 h-3" />
</Combobox.Icon> </Combobox.Icon>

View File

@@ -1,8 +1,15 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { activePermissionId, getPermissionQueue } from "../stores/instances" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import { loadMessages, setActiveSession } from "../stores/sessions" import {
activeInterruption,
getPermissionQueue,
getQuestionQueue,
getQuestionEnqueuedAtForInstance,
sendPermissionResponse,
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
@@ -88,24 +95,111 @@ function resolveToolCallFromPermission(
return null return null
} }
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
const sessionId = getQuestionSessionId(request)
const messageId = getQuestionMessageId(request)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const callId = getQuestionCallId(request)
if (!callId) return null
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId !== callId) continue
if (typeof part.id !== "string" || part.id.length === 0) continue
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord?.revision ?? 0,
}
}
return null
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => { const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null) const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
const queue = createMemo(() => getPermissionQueue(props.instanceId)) const setPermissionBusy = (permissionId: string, busy: boolean) => {
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) setPermissionSubmitting((prev) => {
const next = new Set(prev)
if (busy) next.add(permissionId)
else next.delete(permissionId)
return next
})
}
const orderedQueue = createMemo(() => { const setPermissionItemError = (permissionId: string, message: string | null) => {
const current = queue() setPermissionError((prev) => {
const activeId = activePermId() const next = new Map(prev)
if (!activeId) return current if (!message) next.delete(permissionId)
const index = current.findIndex((entry) => entry.id === activeId) else next.set(permissionId, message)
if (index <= 0) return current return next
const active = current[index] })
if (!active) return current }
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
const permissionId = permission?.id
if (!permissionId) return
if (permissionSubmitting().has(permissionId)) return
setPermissionBusy(permissionId, true)
setPermissionItemError(permissionId, null)
try {
const sessionId = getPermissionSessionId(permission) || ""
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
} catch (error) {
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
} finally {
setPermissionBusy(permissionId, false)
}
}
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
type InterruptionItem =
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
const orderedQueue = createMemo<InterruptionItem[]>(() => {
const permissions = permissionQueue().map((permission) => ({
kind: "permission" as const,
id: permission.id,
sessionId: getPermissionSessionId(permission) || "",
createdAt: (permission as any)?.time?.created ?? Date.now(),
payload: permission,
}))
const questions = questionQueue().map((question) => ({
kind: "question" as const,
id: question.id,
sessionId: getQuestionSessionId(question) || "",
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
payload: question,
}))
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
}) })
const hasPermissions = createMemo(() => queue().length > 0) const hasRequests = createMemo(() => orderedQueue().length > 0)
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -122,7 +216,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
createEffect(() => { createEffect(() => {
if (!props.isOpen) return if (!props.isOpen) return
if (queue().length === 0) { if (orderedQueue().length === 0) {
props.onClose() props.onClose()
} }
}) })
@@ -145,7 +239,14 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
function handleGoToSession(sessionId: string) { function handleGoToSession(sessionId: string) {
if (!sessionId) return if (!sessionId) return
setActiveSession(props.instanceId, sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
}
setActiveSessionFromList(props.instanceId, sessionId)
props.onClose() props.onClose()
} }
@@ -156,10 +257,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<div class="permission-center-modal-header"> <div class="permission-center-modal-header">
<div class="permission-center-modal-title-row"> <div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title"> <h2 id="permission-center-title" class="permission-center-modal-title">
Permissions Requests
</h2> </h2>
<Show when={queue().length > 0}> <Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{queue().length}</span> <span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show> </Show>
</div> </div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close"> <button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
@@ -168,16 +269,40 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
</div> </div>
<div class="permission-center-modal-body"> <div class="permission-center-modal-body">
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}> <Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
<div class="permission-center-list" role="list"> <div class="permission-center-list" role="list">
<For each={orderedQueue()}> <For each={orderedQueue()}>
{(permission) => { {(item) => {
const sessionId = getPermissionSessionId(permission) || "" const isActive = () => active()?.kind === item.kind && active()?.id === item.id
const isActive = () => permission.id === activePermId() const sessionId = () => item.sessionId
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
const resolved = createMemo(() => {
if (item.kind === "permission") {
return resolveToolCallFromPermission(props.instanceId, item.payload)
}
return resolveToolCallFromQuestion(props.instanceId, item.payload)
})
const showFallback = () => !resolved() const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
const primaryTitle = () => {
if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
}
const secondaryTitle = () => {
if (item.kind === "permission") {
return getPermissionKind(item.payload)
}
const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions`
}
return ( return (
<div <div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`} class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
@@ -185,7 +310,8 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
> >
<div class="permission-center-item-header"> <div class="permission-center-item-header">
<div class="permission-center-item-heading"> <div class="permission-center-item-heading">
<span class="permission-center-item-kind">{getPermissionKind(permission)}</span> <span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}> <Show when={isActive()}>
<span class="permission-center-item-chip">Active</span> <span class="permission-center-item-chip">Active</span>
</Show> </Show>
@@ -195,7 +321,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button <button
type="button" type="button"
class="permission-center-item-action" class="permission-center-item-action"
onClick={() => handleGoToSession(sessionId)} onClick={(e) => {
e.stopPropagation()
handleGoToSession(sessionId())
}}
> >
Go to Session Go to Session
</button> </button>
@@ -203,26 +332,64 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button <button
type="button" type="button"
class="permission-center-item-action" class="permission-center-item-action"
disabled={loadingSession() === sessionId} disabled={loadingSession() === sessionId()}
onClick={() => handleLoadSession(sessionId)} onClick={(e) => {
e.stopPropagation()
handleLoadSession(sessionId())
}}
> >
{loadingSession() === sessionId ? "Loading…" : "Load Session"} {loadingSession() === sessionId() ? "Loading…" : "Load Session"}
</button> </button>
</Show> </Show>
</div> </div>
</div> </div>
<Show <Show
when={resolved()} when={resolved()}
fallback={ fallback={
<div class="permission-center-fallback"> <div class="permission-center-fallback">
<div class="permission-center-fallback-title"> <div class="permission-center-fallback-title">
<code>{getPermissionDisplayTitle(permission)}</code> <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>
<div class="permission-center-fallback-hint">Load session for more information.</div> }
</div> >
}
>
{(data) => ( {(data) => (
<ToolCall <ToolCall
toolCall={data().toolPart} toolCall={data().toolPart}

View File

@@ -1,6 +1,6 @@
import { Show, createMemo, type Component } from "solid-js" import { Show, createMemo, type Component } from "solid-js"
import { ShieldAlert } from "lucide-solid" import { ShieldAlert } from "lucide-solid"
import { getPermissionQueueLength } from "../stores/instances" import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
interface PermissionNotificationBannerProps { interface PermissionNotificationBannerProps {
instanceId: string instanceId: string
@@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps {
} }
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => { const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
const hasPermissions = createMemo(() => queueLength() > 0) const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
const queueLength = createMemo(() => permissionCount() + questionCount())
const hasRequests = createMemo(() => queueLength() > 0)
const label = createMemo(() => { const label = createMemo(() => {
const count = queueLength() const total = queueLength()
return `${count} permission${count === 1 ? "" : "s"} pending approval` const parts: string[] = []
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
const detail = parts.length ? ` (${parts.join(", ")})` : ""
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
}) })
return ( return (
<Show when={hasPermissions()}> <Show when={hasRequests()}>
<button <button
type="button" type="button"
class="permission-center-trigger" class="permission-center-trigger"

View File

@@ -1,6 +1,7 @@
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js" import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { addToHistory, getHistory } from "../stores/message-history" import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -46,9 +47,16 @@ export default function PromptInput(props: PromptInputProps) {
const [pasteCount, setPasteCount] = createSignal(0) const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal") const [mode, setMode] = createSignal<"normal" | "shell">("normal")
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
const SELECTION_INSERT_MAX_LENGTH = 2000 const SELECTION_INSERT_MAX_LENGTH = 2000
let textareaRef: HTMLTextAreaElement | undefined let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const getPlaceholder = () => {
if (mode() === "shell") {
return "Run a shell command (Esc to exit)..."
}
return "Type your message, @file, @agent, or paste images and text..."
}
@@ -596,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
} }
} }
setExpandState("normal")
clearPrompt() clearPrompt()
// Ignore attachments for slash commands, but keep them for next prompt. // Ignore attachments for slash commands, but keep them for next prompt.
@@ -615,7 +624,7 @@ export default function PromptInput(props: PromptInputProps) {
// Record attempted slash commands even if execution fails. // Record attempted slash commands even if execution fails.
void refreshHistory() void refreshHistory()
} }
try { try {
if (isShellMode) { if (isShellMode) {
if (props.onRunShell) { if (props.onRunShell) {
@@ -642,7 +651,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus() textareaRef?.focus()
} }
} }
function focusTextareaEnd() { function focusTextareaEnd() {
if (!textareaRef) return if (!textareaRef) return
setTimeout(() => { setTimeout(() => {
@@ -652,7 +661,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef.focus() textareaRef.focus()
}, 0) }, 0)
} }
function canUseHistory(force = false) { function canUseHistory(force = false) {
if (force) return true if (force) return true
if (showPicker()) return false if (showPicker()) return false
@@ -660,29 +669,29 @@ export default function PromptInput(props: PromptInputProps) {
if (!textarea) return false if (!textarea) return false
return textarea.selectionStart === 0 && textarea.selectionEnd === 0 return textarea.selectionStart === 0 && textarea.selectionEnd === 0
} }
function selectPreviousHistory(force = false) { function selectPreviousHistory(force = false) {
const entries = history() const entries = history()
if (entries.length === 0) return false if (entries.length === 0) return false
if (!canUseHistory(force)) return false if (!canUseHistory(force)) return false
if (historyIndex() === -1) { if (historyIndex() === -1) {
setHistoryDraft(prompt()) setHistoryDraft(prompt())
} }
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1) const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex) setHistoryIndex(newIndex)
setPrompt(entries[newIndex]) setPrompt(entries[newIndex])
focusTextareaEnd() focusTextareaEnd()
return true return true
} }
function selectNextHistory(force = false) { function selectNextHistory(force = false) {
const entries = history() const entries = history()
if (entries.length === 0) return false if (entries.length === 0) return false
if (!canUseHistory(force)) return false if (!canUseHistory(force)) return false
if (historyIndex() === -1) return false if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1 const newIndex = historyIndex() - 1
if (newIndex >= 0) { if (newIndex >= 0) {
setHistoryIndex(newIndex) setHistoryIndex(newIndex)
@@ -696,12 +705,18 @@ export default function PromptInput(props: PromptInputProps) {
focusTextareaEnd() focusTextareaEnd()
return true return true
} }
function handleAbort() { function handleAbort() {
if (!props.onAbortSession || !props.isSessionBusy) return if (!props.onAbortSession || !props.isSessionBusy) return
void props.onAbortSession() void props.onAbortSession()
} }
function handleExpandToggle(nextState: "normal" | "expanded") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function handleInput(e: Event) { function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement const target = e.target as HTMLTextAreaElement
@@ -765,9 +780,9 @@ export default function PromptInput(props: PromptInputProps) {
item: item:
| { type: "agent"; agent: Agent } | { type: "agent"; agent: Agent }
| { | {
type: "file" type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
} }
| { type: "command"; command: SDKCommand }, | { type: "command"; command: SDKCommand },
) { ) {
if (item.type === "command") { if (item.type === "command") {
@@ -829,7 +844,10 @@ export default function PromptInput(props: PromptInputProps) {
const currentPrompt = prompt() const currentPrompt = prompt()
const pos = atPosition() const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0 const cursorPos = textareaRef?.selectionStart || 0
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) { if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1) const before = currentPrompt.substring(0, pos + 1)
@@ -873,7 +891,7 @@ export default function PromptInput(props: PromptInputProps) {
if (pos !== null) { if (pos !== null) {
const before = currentPrompt.substring(0, pos) const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos) const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${filename}` const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt) setPrompt(newPrompt)
@@ -1018,18 +1036,18 @@ export default function PromptInput(props: PromptInputProps) {
} }
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession) const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
const hasHistory = () => history().length > 0 const hasHistory = () => history().length > 0
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1) const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
const canHistoryGoNext = () => historyIndex() >= 0 const canHistoryGoNext = () => historyIndex() >= 0
const canSend = () => { const canSend = () => {
if (props.disabled) return false if (props.disabled) return false
const hasText = prompt().trim().length > 0 const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText if (mode() === "shell") return hasText
return hasText || attachments().length > 0 return hasText || attachments().length > 0
} }
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" }) const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
const commandHint = () => ({ key: "/", text: "Commands" }) const commandHint = () => ({ key: "/", text: "Commands" })
@@ -1040,7 +1058,6 @@ export default function PromptInput(props: PromptInputProps) {
return ( return (
<div class="prompt-input-container"> <div class="prompt-input-container">
<div <div
ref={containerRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`} class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={ style={
isDragging() isDragging()
@@ -1067,188 +1084,92 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<Show when={attachments().length > 0}> <div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
<For each={attachments()}> <div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
{(attachment) => {
const isImage = attachment.mediaType.startsWith("image/")
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
const isTextAttachment = typeof textValue === "string"
return (
<div
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={textValue}
>
<Show
when={isImage}
fallback={
<Show
when={isTextAttachment}
fallback={
<Show
when={attachment.source.type === "agent"}
fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</Show>
}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</Show>
}
>
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
</Show>
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
<Show when={isTextAttachment}>
<button
onClick={() => handleExpandTextAttachment(attachment)}
class="attachment-expand"
aria-label="Expand pasted text"
title="Insert pasted text"
>
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
</svg>
</button>
</Show>
<button
onClick={() => handleRemoveAttachment(attachment.id)}
class="attachment-remove"
aria-label="Remove attachment"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={attachment.filename} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<div class="prompt-input-field-container">
<div class="prompt-input-field">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
placeholder={ placeholder={getPlaceholder()}
mode() === "shell" value={prompt()}
? "Run a shell command (Esc to exit)..." onInput={handleInput}
: "Type your message, @file, @agent, or paste images and text..." onKeyDown={handleKeyDown}
} onPaste={handlePaste}
value={prompt()} onFocus={() => setIsFocused(true)}
onInput={handleInput} onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown} disabled={props.disabled}
onPaste={handlePaste} rows={expandState() === "expanded" ? 15 : 4}
onFocus={() => setIsFocused(true)} spellcheck={false}
onBlur={() => setIsFocused(false)} autocorrect="off"
disabled={props.disabled} autoCapitalize="off"
rows={4} autocomplete="off"
style={attachments().length > 0 ? { "padding-top": "8px" } : {}} />
spellcheck={false} <div class="prompt-nav-buttons">
autocorrect="off" <ExpandButton
autoCapitalize="off" expandState={expandState}
autocomplete="off" onToggleExpand={handleExpandToggle}
/> />
<Show when={hasHistory()}> <Show when={hasHistory()}>
<div class="prompt-history-top"> <button
<button type="button"
type="button" class="prompt-history-button"
class="prompt-history-button" onClick={() => selectPreviousHistory(true)}
onClick={() => selectPreviousHistory(true)} disabled={!canHistoryGoPrevious()}
disabled={!canHistoryGoPrevious()} aria-label="Previous prompt"
aria-label="Previous prompt" >
> <ArrowBigUp class="h-5 w-5" aria-hidden="true" />
<ArrowBigUp class="h-5 w-5" aria-hidden="true" /> </button>
</button> <button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div> </div>
<div class="prompt-history-bottom"> <Show when={shouldShowOverlay()}>
<button <div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
type="button" <Show
class="prompt-history-button" when={props.escapeInDebounce}
onClick={() => selectNextHistory(true)} fallback={
disabled={!canHistoryGoNext()} <>
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text"> <span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text} <Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span> </span>
</Show> <Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}> <Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span> <span class="prompt-overlay-shell-active">Shell mode active</span>
</Show> </Show>
</> </>
} </Show>
> </div>
<> </Show>
<span class="prompt-overlay-text prompt-overlay-warning"> </div>
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</div> </div>
</div> </div>
</div>
<div class="prompt-input-actions"> <div class="prompt-input-actions">
<button <button

View File

@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null) const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({}) const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null) const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? []) const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode) const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const refreshMeta = async () => { const refreshMeta = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
setPasswordError(null)
try { try {
const result = await serverApi.fetchServerMeta() const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(result) setMeta(metaResult)
setAuthStatus(authResult)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
} }
} }
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError("Password must be at least 8 characters.")
return
}
if (next !== confirm) {
setPasswordError("Passwords do not match.")
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return ( return (
<Dialog <Dialog
open={props.open} open={props.open}
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</section> </section>
<section class="remote-section"> <section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Server password</p>
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
</div>
</div>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">Authentication status unavailable.</div>}
>
<div class="remote-card">
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
<p class="remote-help">
{authStatus()!.passwordUserProvided
? "A password is set for remote access."
: "No memorable password is set yet. Set one to allow remote handover logins."}
</p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? "Cancel"
: authStatus()!.passwordUserProvided
? "Change password"
: "Set password"}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">New password</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder="At least 8 characters"
/>
</div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">Confirm password</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
</Show>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
disabled={savingPassword()}
onClick={() => void handleSubmitPassword()}
>
{savingPassword() ? "Saving…" : "Save password"}
</button>
</div>
</Show>
</div>
</Show>
</section>
<section class="remote-section">
<div class="remote-section-heading"> <div class="remote-section-heading">
<div class="remote-section-title"> <div class="remote-section-title">
<Wifi class="remote-icon" /> <Wifi class="remote-icon" />

View File

@@ -1,5 +1,5 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { Session, SessionStatus } from "../types/session" import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state" import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status" import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
@@ -14,6 +14,7 @@ import {
isSessionParentExpanded, isSessionParentExpanded,
loading, loading,
renameSession, renameSession,
sessions as sessionStateSessions,
setActiveSessionFromList, setActiveSessionFromList,
toggleSessionParentExpanded, toggleSessionParentExpanded,
} from "../stores/sessions" } from "../stores/sessions"
@@ -25,7 +26,6 @@ const log = getLogger("session")
interface SessionListProps { interface SessionListProps {
instanceId: string instanceId: string
sessions: Map<string, Session>
threads: SessionThread[] threads: SessionThread[]
activeSessionId: string | null activeSessionId: string | null
onSelect: (sessionId: string) => void onSelect: (sessionId: string) => void
@@ -58,7 +58,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => { const selectSession = (sessionId: string) => {
const session = props.sessions.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id const parentId = session?.parentId ?? session?.id
if (parentId) { if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId) ensureSessionParentExpanded(props.instanceId, parentId)
@@ -132,7 +132,7 @@ const SessionList: Component<SessionListProps> = (props) => {
} }
const openRenameDialog = (sessionId: string) => { const openRenameDialog = (sessionId: string) => {
const session = props.sessions.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
if (!session) return if (!session) return
const label = session.title && session.title.trim() ? session.title : sessionId const label = session.title && session.title.trim() ? session.title : sessionId
setRenameTarget({ id: sessionId, title: session.title ?? "", label }) setRenameTarget({ id: sessionId, title: session.title ?? "", label })
@@ -167,7 +167,7 @@ const SessionList: Component<SessionListProps> = (props) => {
expanded?: boolean expanded?: boolean
onToggleExpand?: () => void onToggleExpand?: () => void
}> = (rowProps) => { }> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId) const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
if (!session()) { if (!session()) {
return <></> return <></>
} }
@@ -175,9 +175,11 @@ const SessionList: Component<SessionListProps> = (props) => {
const title = () => session()?.title || "Untitled" const title = () => session()?.title || "Untitled"
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status()) const statusLabel = () => formatSessionStatus(status())
const pendingPermission = () => Boolean(session()?.pendingPermission) const needsPermission = () => Boolean(session()?.pendingPermission)
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel()) const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
return ( return (
<div class="session-list-item group"> <div class="session-list-item group">
@@ -224,7 +226,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</span> </span>
</Show> </Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{pendingPermission() ? ( {needsInput() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : ( ) : (
<span class="status-dot" /> <span class="status-dot" />
@@ -291,7 +293,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const activeId = props.activeSessionId const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null if (!activeId || activeId === "info") return null
const activeSession = props.sessions.get(activeId) const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return null if (!activeSession) return null
return activeSession.parentId ?? activeSession.id return activeSession.parentId ?? activeSession.id

View File

@@ -1,10 +1,13 @@
import { Show, createMemo, createEffect, type Component } from "solid-js" import { Show, For, createMemo, createEffect, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
import MessageSection from "../message-section" import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus" import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input" import PromptInput from "../prompt-input"
import type { Attachment as PromptAttachment } from "../../types/attachment"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
@@ -39,6 +42,62 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!currentSession) return false if (!currentSession) return false
return getSessionBusyStatus(props.instanceId, currentSession.id) return getSessionBusyStatus(props.instanceId, currentSession.id)
}) })
const sessionNeedsInput = createMemo(() => {
const currentSession = session()
if (!currentSession) return false
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
})
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
function handleExpandTextAttachment(attachment: PromptAttachment) {
if (attachment.source.type !== "text") return
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
const value = attachment.source.value
const match = attachment.display.match(/pasted #(\d+)/)
const placeholder = match ? `[pasted #${match[1]}]` : null
const currentText = textarea?.value ?? ""
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
if (textarea) {
textarea.value = nextText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}
removeAttachment(props.instanceId, props.sessionId, attachment.id)
}
let scrollToBottomHandle: (() => void) | undefined let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined let rootRef: HTMLDivElement | undefined
function scheduleScrollToBottom() { function scheduleScrollToBottom() {
@@ -224,17 +283,52 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/> />
<PromptInput <Show when={attachments().length > 0}>
instanceId={props.instanceId} <div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
instanceFolder={props.instanceFolder} <For each={attachments()}>
sessionId={activeSession.id} {(attachment) => {
onSend={handleSendMessage} const isText = attachment.source.type === "text"
onRunShell={handleRunShell} return (
escapeInDebounce={props.escapeInDebounce} <div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
isSessionBusy={sessionBusy()} <span class="font-mono">{attachment.display}</span>
onAbortSession={handleAbortSession} <Show when={isText}>
registerQuoteHandler={registerQuoteHandler} <button
/> type="button"
class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)}
aria-label="Expand pasted text"
title="Insert pasted text"
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label="Remove attachment"
>
×
</button>
</div>
)
}}
</For>
</div>
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
</div> </div>
) )
}} }}

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,15 +1,20 @@
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
import { sendPermissionResponse } from "../stores/instances" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission" import { getPermissionSessionId } from "../types/permission"
import type { TextPart, RenderCache } from "../types/message" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { resolveToolRenderer } from "./tool-call/renderers" 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 { import type {
DiffPayload, DiffPayload,
DiffRenderOptions, DiffRenderOptions,
@@ -22,15 +27,11 @@ import type {
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title" import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { escapeHtml } from "../lib/markdown"
const log = getLogger("session") const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk").ToolState type ToolState = import("@opencode-ai/sdk").ToolState
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600 const TOOL_SCROLL_INTENT_WINDOW_MS = 600
@@ -61,162 +62,7 @@ interface ToolCallProps {
interface LspRangePosition {
line?: number
character?: number
}
interface LspRange {
start?: LspRangePosition
}
interface LspDiagnostic {
message?: string
severity?: number
range?: LspRange
}
interface DiagnosticEntry {
id: string
severity: number
tone: "error" | "warning" | "info"
label: string
icon: string
message: string
filePath: string
displayPath: string
line: number
column: number
}
function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
}
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
if (!state) return []
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
if (!supportsMetadata) return []
const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
if (!diagnosticsMap) return []
const preferredPath = [
input.filePath,
metadata.filePath,
metadata.filepath,
input.path,
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const prioritizedEntries = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
return normalized === normalizedPreferred
})
if (prioritizedEntries.length === 0) return []
const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) {
if (!Array.isArray(list)) continue
const normalizedPath = normalizeDiagnosticPath(pathKey)
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
}
return entries.sort((a, b) => a.severity - b.severity)
}
function diagnosticFileName(entries: DiagnosticEntry[]) {
const first = entries[0]
return first ? first.displayPath : ""
}
function renderDiagnosticsSection(
entries: DiagnosticEntry[],
expanded: boolean,
toggle: () => void,
fileLabel: string,
) {
if (entries.length === 0) return null
return (
<div class="tool-call-diagnostics-wrapper">
<button
type="button"
class="tool-call-diagnostics-heading"
aria-expanded={expanded}
onClick={toggle}
>
<span class="tool-call-icon" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">
:L{entry.line || "-"}:C{entry.column || "-"}
</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
export default function ToolCall(props: ToolCallProps) { export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig() const { preferences, setDiffViewMode } = useConfig()
@@ -239,6 +85,7 @@ export default function ToolCall(props: ToolCallProps) {
})) }))
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
const cacheVersion = createMemo(() => { const cacheVersion = createMemo(() => {
if (typeof props.partVersion === "number") { if (typeof props.partVersion === "number") {
@@ -250,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
return "noversion" return "noversion"
}) })
const messageVersionAccessor = createMemo(() => props.messageVersion)
const partVersionAccessor = createMemo(() => props.partVersion)
const createVariantCache = (variant: string | (() => string), version?: () => string) => const createVariantCache = (variant: string | (() => string), version?: () => string) =>
useGlobalCache({ useGlobalCache({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
@@ -267,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
const permissionDiffCache = createVariantCache("permission-diff") const permissionDiffCache = createVariantCache("permission-diff")
const ansiRunningCache = createVariantCache("ansi-running", () => "running") const ansiRunningCache = createVariantCache("ansi-running", () => "running")
const ansiFinalCache = createVariantCache("ansi-final") const ansiFinalCache = createVariantCache("ansi-final")
const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = ""
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier())) const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
const pendingPermission = createMemo(() => { const pendingPermission = createMemo(() => {
@@ -278,6 +126,16 @@ export default function ToolCall(props: ToolCallProps) {
} }
return toolCallMemo()?.pendingPermission return toolCallMemo()?.pendingPermission
}) })
const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier()))
const pendingQuestion = createMemo(() => {
const state = questionState()
if (state) {
return { request: state.entry.request as QuestionRequest, active: state.active }
}
return undefined
})
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
@@ -292,27 +150,45 @@ export default function ToolCall(props: ToolCallProps) {
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null) const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const isPermissionActive = createMemo(() => {
const pending = pendingPermission()
if (!pending?.permission) return false
const active = activeRequest()
return active?.kind === "permission" && active.id === pending.permission.id
})
const isQuestionActive = createMemo(() => {
const pending = pendingQuestion()
if (!pending?.request) return false
const active = activeRequest()
return active?.kind === "question" && active.id === pending.request.id
})
const expanded = () => { const expanded = () => {
const permission = pendingPermission() if (isPermissionActive() || isQuestionActive()) return true
if (permission?.active) return true
const override = userExpanded() const override = userExpanded()
if (override !== null) return override if (override !== null) return override
return defaultExpandedForTool() return defaultExpandedForTool()
} }
const permissionDetails = createMemo(() => pendingPermission()?.permission) const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true) const questionDetails = createMemo(() => pendingQuestion()?.request)
const activePermissionKey = createMemo(() => { const activePermissionKey = createMemo(() => {
const permission = permissionDetails() const permission = permissionDetails()
return permission && isPermissionActive() ? permission.id : "" return permission && isPermissionActive() ? permission.id : ""
}) })
const activeQuestionKey = createMemo(() => {
const request = questionDetails()
return request && isQuestionActive() ? request.id : ""
})
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null) const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined) const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
const diagnosticsExpanded = () => { const diagnosticsExpanded = () => {
const permission = pendingPermission() if (isPermissionActive() || isQuestionActive()) return true
if (permission?.active) return true
const override = diagnosticsOverride() const override = diagnosticsOverride()
if (override !== undefined) return override if (override !== undefined) return override
return diagnosticsDefaultExpanded() return diagnosticsDefaultExpanded()
@@ -513,7 +389,7 @@ export default function ToolCall(props: ToolCallProps) {
}) })
createEffect(() => { createEffect(() => {
const activeKey = activePermissionKey() const activeKey = activePermissionKey() || activeQuestionKey()
if (!activeKey) return if (!activeKey) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" }) toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
@@ -524,15 +400,94 @@ export default function ToolCall(props: ToolCallProps) {
const activeKey = activePermissionKey() const activeKey = activePermissionKey()
if (!activeKey) return if (!activeKey) return
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
const permission = permissionDetails()
if (!permission || !isPermissionActive()) return
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
handlePermissionResponse("once") void handlePermissionResponse(permission, "once")
} else if (event.key === "a" || event.key === "A") { } else if (event.key === "a" || event.key === "A") {
event.preventDefault() event.preventDefault()
handlePermissionResponse("always") void handlePermissionResponse(permission, "always")
} else if (event.key === "d" || event.key === "D") { } else if (event.key === "d" || event.key === "D") {
event.preventDefault() event.preventDefault()
handlePermissionResponse("reject") void handlePermissionResponse(permission, "reject")
}
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
})
const [questionSubmitting, setQuestionSubmitting] = createSignal(false)
const [questionError, setQuestionError] = createSignal<string | null>(null)
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
function isTextInputFocused() {
const active = document.activeElement
return (
active?.tagName === "TEXTAREA" ||
active?.tagName === "INPUT" ||
(active?.hasAttribute("contenteditable") ?? false)
)
}
async function handleQuestionSubmit() {
const request = questionDetails()
if (!request || !isQuestionActive()) {
return
}
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
const normalized = request.questions.map((_, index) => {
const row = answers[index] ?? []
return row.map((value) => value.trim()).filter((value) => value.length > 0)
})
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
setQuestionError("Please answer all questions before submitting.")
return
}
setQuestionSubmitting(true)
setQuestionError(null)
try {
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
} catch (error) {
log.error("Failed to send question reply", error)
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
} finally {
setQuestionSubmitting(false)
}
}
async function handleQuestionDismiss() {
const request = questionDetails()
if (!request || !isQuestionActive()) {
return
}
setQuestionSubmitting(true)
setQuestionError(null)
try {
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
await sendQuestionReject(props.instanceId, sessionId, request.id)
} catch (error) {
log.error("Failed to reject question", error)
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
} finally {
setQuestionSubmitting(false)
}
}
createEffect(() => {
const activeKey = activeQuestionKey()
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
if (isTextInputFocused()) return
if (event.key === "Enter") {
event.preventDefault()
void handleQuestionSubmit()
} else if (event.key === "Escape") {
event.preventDefault()
void handleQuestionDismiss()
} }
} }
document.addEventListener("keydown", handler) document.addEventListener("keydown", handler)
@@ -563,7 +518,7 @@ export default function ToolCall(props: ToolCallProps) {
const combinedStatusClass = () => { const combinedStatusClass = () => {
const base = statusClass() const base = statusClass()
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base return pendingPermission() || pendingQuestion() ? `${base} tool-call-awaiting-permission` : base
} }
function toggle() { function toggle() {
@@ -579,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
const renderer = createMemo(() => resolveToolRenderer(toolName())) const renderer = createMemo(() => resolveToolRenderer(toolName()))
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) { const { renderAnsiContent } = createAnsiContentRenderer({
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" ansiRunningCache,
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff") ansiFinalCache,
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" scrollHelpers,
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache partVersion: partVersionAccessor,
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode })
const themeKey = isDark() ? "dark" : "light"
let cachedHtml: string | undefined const { renderDiffContent } = createDiffContentRenderer({
const cached = cacheHandle.get<RenderCache>() preferences,
const currentMode = diffMode() setDiffViewMode,
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { isDark,
cachedHtml = cached.html diffCache,
} permissionDiffCache,
scrollHelpers,
handleScrollRendered,
onContentRendered: props.onContentRendered,
})
const handleModeChange = (mode: DiffViewMode) => { const { renderMarkdownContent } = createMarkdownContentRenderer({
setDiffViewMode(mode) toolState,
} partId: toolCallIdentifier,
partVersion: partVersionAccessor,
const handleDiffRendered = () => { instanceId: props.instanceId,
if (!options?.disableScrollTracking) { sessionId: props.sessionId,
handleScrollRendered() isDark,
} scrollHelpers,
props.onContentRendered?.() handleScrollRendered,
} onContentRendered: 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 rendererContext: ToolRendererContext = { const rendererContext: ToolRendererContext = {
toolCall: toolCallMemo, toolCall: toolCallMemo,
@@ -831,11 +630,8 @@ export default function ToolCall(props: ToolCallProps) {
return renderer().renderBody(rendererContext) return renderer().renderBody(rendererContext)
} }
async function handlePermissionResponse(response: "once" | "always" | "reject") { async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
const permission = permissionDetails() if (!permission) return
if (!permission || !isPermissionActive()) {
return
}
setPermissionSubmitting(true) setPermissionSubmitting(true)
setPermissionError(null) setPermissionError(null)
try { try {
@@ -863,92 +659,50 @@ export default function ToolCall(props: ToolCallProps) {
} }
const renderPermissionBlock = () => { const renderPermissionBlock = () => (
const permission = permissionDetails() <PermissionToolBlock
if (!permission) return null permission={permissionDetails}
const active = isPermissionActive() active={isPermissionActive}
const metadata = (permission.metadata ?? {}) as Record<string, unknown> submitting={permissionSubmitting}
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null error={permissionError}
const diffPathRaw = (() => { renderDiff={renderDiffContent}
if (typeof metadata.filePath === "string") { fallbackSessionId={() => props.sessionId}
return metadata.filePath as string onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
} />
if (typeof metadata.path === "string") { )
return metadata.path as string
}
return undefined
})()
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
return ( const renderQuestionBlock = () => (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}> <QuestionToolBlock
<div class="tool-call-permission-header"> toolName={toolName}
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span> toolState={toolState}
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span> toolCallId={toolCallIdentifier}
</div> request={questionDetails}
<div class="tool-call-permission-body"> active={isQuestionActive}
<div class="tool-call-permission-title"> submitting={questionSubmitting}
<code>{getPermissionDisplayTitle(permission)}</code> error={questionError}
</div> draftAnswers={questionDraftAnswers}
<Show when={diffPayload}> setDraftAnswers={setQuestionDraftAnswers}
{(payload) => ( onSubmit={() => void handleQuestionSubmit()}
<div class="tool-call-permission-diff"> onDismiss={() => void handleQuestionDismiss()}
{renderDiffContent(payload(), { />
variant: "permission-diff", )
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff", createEffect(() => {
})} const request = questionDetails()
</div> if (!request) {
)} setQuestionSubmitting(false)
</Show> setQuestionError(null)
<Show return
when={active} }
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>} setQuestionError(null)
> const requestId = request.id
<div class="tool-call-permission-actions"> setQuestionDraftAnswers((prev) => {
<div class="tool-call-permission-buttons"> if (prev[requestId]) return prev
<button const initial = request.questions.map(() => [])
type="button" return { ...prev, [requestId]: initial }
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 status = () => toolState()?.status || "" const status = () => toolState()?.status || ""
@@ -993,6 +747,7 @@ export default function ToolCall(props: ToolCallProps) {
{renderError()} {renderError()}
{renderPermissionBlock()} {renderPermissionBlock()}
{renderQuestionBlock()}
<Show when={status() === "pending" && !pendingPermission()}> <Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message"> <div class="tool-call-pending-message">

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 { bashRenderer } from "./bash"
import { defaultRenderer } from "./default" import { defaultRenderer } from "./default"
import { editRenderer } from "./edit" import { editRenderer } from "./edit"
import { applyPatchRenderer } from "./apply-patch"
import { patchRenderer } from "./patch" import { patchRenderer } from "./patch"
import { readRenderer } from "./read" import { readRenderer } from "./read"
import { taskRenderer } from "./task" import { taskRenderer } from "./task"
@@ -9,16 +10,19 @@ import { todoRenderer } from "./todo"
import { webfetchRenderer } from "./webfetch" import { webfetchRenderer } from "./webfetch"
import { writeRenderer } from "./write" import { writeRenderer } from "./write"
import { invalidRenderer } from "./invalid" import { invalidRenderer } from "./invalid"
import { questionRenderer } from "./question"
const TOOL_RENDERERS: ToolRenderer[] = [ const TOOL_RENDERERS: ToolRenderer[] = [
bashRenderer, bashRenderer,
readRenderer, readRenderer,
writeRenderer, writeRenderer,
editRenderer, editRenderer,
applyPatchRenderer,
patchRenderer, patchRenderer,
webfetchRenderer, webfetchRenderer,
todoRenderer, todoRenderer,
taskRenderer, taskRenderer,
questionRenderer,
invalidRenderer, invalidRenderer,
] ]

View File

@@ -0,0 +1,17 @@
import type { ToolRenderer } from "../types"
export const questionRenderer: ToolRenderer = {
tools: ["question"],
getAction: () => "Awaiting answers...",
getTitle({ toolState }) {
const state = toolState()
if (!state) return "Questions"
if (state.status === "completed") return "Questions"
return "Asking questions"
},
renderBody() {
// The question tool UI is rendered by ToolCall itself so
// it can share the same layout for pending/completed.
return null
},
}

View File

@@ -1,8 +1,7 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createMemo } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { getTodoTitle } from "./todo"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
interface TaskSummaryItem { interface TaskSummaryItem {
@@ -90,7 +89,51 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
return describeTaskTitle(input) 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(() => { const items = createMemo(() => {
// Track the reactive change points so we only recompute when the part/message changes // Track the reactive change points so we only recompute when the part/message changes
messageVersion?.() messageVersion?.()
@@ -114,41 +157,90 @@ export const taskRenderer: ToolRenderer = {
}) })
}) })
if (items().length === 0) return null
return ( return (
<div <div class="tool-call-task-sections">
class="message-text tool-call-markdown tool-call-task-container" <Show when={promptContent()}>
ref={(element) => scrollHelpers?.registerContainer(element)} <section class="tool-call-task-section">
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined} <header class="tool-call-task-section-header">
> <span class="tool-call-task-section-title">Prompt</span>
<div class="tool-call-task-summary"> <Show when={headerMeta()}>
<For each={items()}> <span class="tool-call-task-section-meta">{headerMeta()}</span>
{(item) => { </Show>
const icon = getToolIcon(item.tool) </header>
const description = describeToolTitle(item) <div class="tool-call-task-section-body">
const toolLabel = getToolName(item.tool) {renderMarkdown({
const status = normalizeStatus(item.status ?? item.state?.status) content: promptContent()!,
const statusIcon = summarizeStatusIcon(status) cacheKey: "task:prompt",
const statusLabel = summarizeStatusLabel(status) disableScrollTracking: true,
const statusAttr = status ?? "pending" disableHighlight: true,
return ( })}
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}> </div>
<span class="tool-call-task-icon">{icon}</span> </section>
<span class="tool-call-task-label">{toolLabel}</span> </Show>
<span class="tool-call-task-separator" aria-hidden="true"></span>
<span class="tool-call-task-text">{description}</span> <Show when={items().length > 0}>
<Show when={statusIcon}> <section class="tool-call-task-section">
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}> <header class="tool-call-task-section-header">
{statusIcon} <span class="tool-call-task-section-title">Steps</span>
</span> <span class="tool-call-task-section-meta">{items().length} steps</span>
</Show> </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> </div>
) {scrollHelpers?.renderSentinel?.()}
}} </div>
</For> </div>
</div> </section>
{scrollHelpers?.renderSentinel?.()} </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> </div>
) )
}, },

View File

@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read" import { readRenderer } from "./renderers/read"
import { writeRenderer } from "./renderers/write" import { writeRenderer } from "./renderers/write"
import { editRenderer } from "./renderers/edit" import { editRenderer } from "./renderers/edit"
import { applyPatchRenderer } from "./renderers/apply-patch"
import { patchRenderer } from "./renderers/patch" import { patchRenderer } from "./renderers/patch"
import { webfetchRenderer } from "./renderers/webfetch" import { webfetchRenderer } from "./renderers/webfetch"
import { todoRenderer } from "./renderers/todo" import { todoRenderer } from "./renderers/todo"
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
read: readRenderer, read: readRenderer,
write: writeRenderer, write: writeRenderer,
edit: editRenderer, edit: editRenderer,
apply_patch: applyPatchRenderer,
patch: patchRenderer, patch: patchRenderer,
webfetch: webfetchRenderer, webfetch: webfetchRenderer,
todowrite: todoRenderer, todowrite: todoRenderer,

View File

@@ -13,6 +13,16 @@ export interface MarkdownRenderOptions {
content: string content: string
size?: "default" | "large" size?: "default" | "large"
disableHighlight?: boolean 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 { export interface AnsiRenderOptions {
@@ -26,6 +36,11 @@ export interface DiffRenderOptions {
variant?: string variant?: string
disableScrollTracking?: boolean disableScrollTracking?: boolean
label?: string 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 { export interface ToolScrollHelpers {

View File

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

View File

@@ -339,7 +339,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
e.preventDefault() e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0)) setSelectedIndex((prev) => Math.max(prev - 1, 0))
scrollToSelected() scrollToSelected()
} else if (e.key === "Enter") { } else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault() e.preventDefault()
const selected = items[selectedIndex()] const selected = items[selectedIndex()]
if (selected) { if (selected) {
@@ -534,7 +534,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div class="dropdown-footer"> <div class="dropdown-footer">
<div> <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 <span class="font-medium">Esc</span> close
</div> </div>
</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, BinaryUpdateRequest,
BinaryValidationResult, BinaryValidationResult,
FileSystemEntry, FileSystemEntry,
FileSystemCreateFolderResponse,
FileSystemListResponse, FileSystemListResponse,
InstanceData, InstanceData,
ServerMeta, ServerMeta,
@@ -103,7 +104,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
logHttp(`${method} ${path}`) logHttp(`${method} ${path}`)
try { try {
const response = await fetch(url, { ...init, headers }) const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) { if (!response.ok) {
const message = await response.text() const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
@@ -135,6 +136,15 @@ export const serverApi = {
fetchServerMeta(): Promise<ServerMeta> { fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta") return request<ServerMeta>("/api/meta")
}, },
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
method: "POST",
body: JSON.stringify({ password }),
})
},
deleteWorkspace(id: string): Promise<void> { deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" }) return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
}, },
@@ -215,6 +225,13 @@ export const serverApi = {
const query = params.toString() const query = params.toString()
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem") 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> { readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`) return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
}, },
@@ -270,7 +287,7 @@ export const serverApi = {
}, },
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
sseLogger.info(`Connecting to ${EVENTS_URL}`) sseLogger.info(`Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL) const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
source.onmessage = (event) => { source.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload const payload = JSON.parse(event.data) as WorkspaceEventPayload

View File

@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
} }
return value.toLocaleString() 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 if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" }) emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
}, },
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
) )
registerEscapeShortcut( 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({ commandRegistry.register({
id: "open-agent-selector", id: "open-agent-selector",
label: "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 { export interface SessionSidebarRequestDetail {
instanceId: string instanceId: string

View File

@@ -1,6 +1,10 @@
import { keyboardRegistry } from "../keyboard-registry" 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") const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({ keyboardRegistry.register({
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
description: "open agent", description: "open agent",
context: "global", 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

@@ -63,6 +63,8 @@ type SSEEvent =
| EventSessionIdle | EventSessionIdle
| { type: "permission.updated" | "permission.asked"; properties?: any } | { type: "permission.updated" | "permission.asked"; properties?: any }
| { type: "permission.replied"; properties?: any } | { type: "permission.replied"; properties?: any }
| { type: "question.asked"; properties?: any }
| { type: "question.replied" | "question.rejected"; properties?: any }
| EventLspUpdated | EventLspUpdated
| TuiToastEvent | TuiToastEvent
| BackgroundProcessUpdatedEvent | BackgroundProcessUpdatedEvent
@@ -144,6 +146,13 @@ class SSEManager {
case "permission.replied": case "permission.replied":
this.onPermissionReplied?.(instanceId, event as any) this.onPermissionReplied?.(instanceId, event as any)
break break
case "question.asked":
this.onQuestionAsked?.(instanceId, event as any)
break
case "question.replied":
case "question.rejected":
this.onQuestionAnswered?.(instanceId, event as any)
break
case "lsp.updated": case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated) this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break break
@@ -178,6 +187,8 @@ class SSEManager {
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onPermissionUpdated?: (instanceId: string, event: any) => void onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => void onPermissionReplied?: (instanceId: string, event: any) => void
onQuestionAsked?: (instanceId: string, event: any) => void
onQuestionAnswered?: (instanceId: string, event: any) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void

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

@@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus } from "@opencode-ai/sdk/v2" import type { LspStatus } from "@opencode-ai/sdk/v2"
import type { PermissionReply, PermissionRequestLike } from "../types/permission" import type { PermissionReply, PermissionRequestLike } from "../types/permission"
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission" import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { getQuestionSessionId } from "../types/question"
import { requestData } from "../lib/opencode-api" import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager" import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager" import { sseManager } from "../lib/sse-manager"
@@ -18,10 +20,10 @@ import {
} from "./sessions" } from "./sessions"
import { fetchCommands, clearCommands } from "./commands" import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { setSessionPendingPermission } from "./session-state" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
import { setHasInstances } from "./ui" import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
import { clearCacheForInstance } from "../lib/global-cache" import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
@@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map()) const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
// Permission queue management per instance // Interruption queues (permissions + questions) per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map()) const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>() const permissionSessionCounts = new Map<string, Map<string, number>>()
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
const questionSessionCounts = new Map<string, Map<string, number>>()
const questionEnqueuedAt = new Map<string, number>()
function ensureQuestionEnqueuedAt(request: QuestionRequest): number {
const existing = questionEnqueuedAt.get(request.id)
if (existing) return existing
const now = Date.now()
questionEnqueuedAt.set(request.id, now)
return now
}
type InterruptionKind = "permission" | "question"
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
const [activeInterruption, setActiveInterruption] = createSignal<Map<string, ActiveInterruption>>(new Map())
function syncHasInstancesFlag() { function syncHasInstancesFlag() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
setHasInstances(readyExists) setHasInstances(readyExists)
@@ -71,6 +92,19 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
} }
} }
function ensureActiveInstanceSelected(): void {
const current = activeInstanceId()
const instanceMap = instances()
if (current && instanceMap.has(current)) return
for (const [id, instance] of instanceMap.entries()) {
if (instance.status === "ready") {
setActiveInstanceId(id)
return
}
}
}
function upsertWorkspace(descriptor: WorkspaceDescriptor) { function upsertWorkspace(descriptor: WorkspaceDescriptor) {
const mapped = workspaceDescriptorToInstance(descriptor) const mapped = workspaceDescriptorToInstance(descriptor)
if (instances().has(descriptor.id)) { if (instances().has(descriptor.id)) {
@@ -81,6 +115,9 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
if (descriptor.status === "ready") { if (descriptor.status === "ready") {
attachClient(descriptor) attachClient(descriptor)
// If no tab is currently selected (common after UI refresh),
// auto-select the first ready instance.
ensureActiveInstanceSelected()
} }
} }
@@ -156,6 +193,38 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
} }
} }
async function syncPendingQuestions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) return
try {
const remote = await requestData<QuestionRequest[]>(
instance.client.question.list(),
"question.list",
)
const remoteIds = new Set(remote.map((item) => item.id))
const local = getQuestionQueue(instanceId)
// Remove any stale local requests missing from server.
for (const entry of local) {
if (!remoteIds.has(entry.id)) {
removeQuestionFromQueue(instanceId, entry.id)
removeQuestionV2(instanceId, entry.id)
}
}
// Upsert all server-side pending questions.
for (const request of remote) {
ensureQuestionEnqueuedAt(request)
addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request)
}
} catch (error) {
log.warn("Failed to sync pending questions", { instanceId, error })
}
}
async function hydrateInstanceData(instanceId: string) { async function hydrateInstanceData(instanceId: string) {
try { try {
await fetchSessions(instanceId) await fetchSessions(instanceId)
@@ -166,20 +235,24 @@ async function hydrateInstanceData(instanceId: string) {
if (!instance?.client) return if (!instance?.client) return
await fetchCommands(instanceId, instance.client) await fetchCommands(instanceId, instance.client)
await syncPendingPermissions(instanceId) await syncPendingPermissions(instanceId)
await syncPendingQuestions(instanceId)
} catch (error) { } catch (error) {
log.error("Failed to fetch initial data", error) log.error("Failed to fetch initial data", error)
} }
} }
void (async function initializeWorkspaces() { void (async function initializeWorkspaces() {
try { try {
const workspaces = await serverApi.fetchWorkspaces() const workspaces = await serverApi.fetchWorkspaces()
workspaces.forEach((workspace) => upsertWorkspace(workspace)) workspaces.forEach((workspace) => upsertWorkspace(workspace))
// After a UI refresh, we may have instances but no active selection.
ensureActiveInstanceSelected()
} catch (error) { } catch (error) {
log.error("Failed to load workspaces", error) log.error("Failed to load workspaces", error)
} }
})() })()
serverEvents.on("*", (event) => handleWorkspaceEvent(event)) serverEvents.on("*", (event) => handleWorkspaceEvent(event))
function handleWorkspaceEvent(event: WorkspaceEventPayload) { function handleWorkspaceEvent(event: WorkspaceEventPayload) {
@@ -327,6 +400,7 @@ function removeInstance(id: string) {
removeLogContainer(id) removeLogContainer(id)
clearCommands(id) clearCommands(id)
clearPermissionQueue(id) clearPermissionQueue(id)
clearQuestionQueue(id)
clearInstanceMetadata(id) clearInstanceMetadata(id)
if (activeInstanceId() === id) { if (activeInstanceId() === id) {
@@ -429,6 +503,79 @@ function getPermissionQueueLength(instanceId: string): number {
return getPermissionQueue(instanceId).length return getPermissionQueue(instanceId).length
} }
function getQuestionQueue(instanceId: string): QuestionRequest[] {
const queue = questionQueues().get(instanceId)
if (!queue) {
return []
}
return queue
}
function getQuestionQueueLength(instanceId: string): number {
return getQuestionQueue(instanceId).length
}
function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number {
// Ensure we have a stable timestamp for sorting/ordering.
const queue = getQuestionQueue(instanceId)
const match = queue.find((q) => q.id === requestId)
if (match) {
return ensureQuestionEnqueuedAt(match)
}
return questionEnqueuedAt.get(requestId) ?? Date.now()
}
function computeActiveInterruption(instanceId: string): ActiveInterruption {
const permissions = getPermissionQueue(instanceId)
const questions = getQuestionQueue(instanceId)
const firstPermission = permissions[0]
const firstQuestion = questions[0]
if (!firstPermission && !firstQuestion) return null
if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id }
if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id }
const permTime = getPermissionCreatedAt(firstPermission)
const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER
if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id }
return { kind: "question", id: firstQuestion!.id }
}
function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void {
setActiveInterruption((prev) => {
const next = new Map(prev)
if (!nextActive) {
next.set(instanceId, null)
} else {
next.set(instanceId, nextActive)
}
return next
})
setActivePermissionId((prev) => {
const next = new Map(prev)
if (nextActive?.kind === "permission") {
next.set(instanceId, nextActive.id)
} else {
next.set(instanceId, null)
}
return next
})
setActiveQuestionId((prev) => {
const next = new Map(prev)
if (nextActive?.kind === "question") {
next.set(instanceId, nextActive.id)
} else {
next.set(instanceId, null)
}
return next
})
}
function recomputeActiveInterruption(instanceId: string): void {
setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId))
}
function incrementSessionPendingCount(instanceId: string, sessionId: string): void { function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = permissionSessionCounts.get(instanceId) let sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) { if (!sessionCounts) {
@@ -464,6 +611,41 @@ function clearSessionPendingCounts(instanceId: string): void {
permissionSessionCounts.delete(instanceId) permissionSessionCounts.delete(instanceId)
} }
function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) {
sessionCounts = new Map()
questionSessionCounts.set(instanceId, sessionCounts)
}
const current = sessionCounts.get(sessionId) ?? 0
sessionCounts.set(sessionId, current + 1)
}
function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number {
const sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) return 0
const current = sessionCounts.get(sessionId) ?? 0
if (current <= 1) {
sessionCounts.delete(sessionId)
if (sessionCounts.size === 0) {
questionSessionCounts.delete(instanceId)
}
return 0
}
const nextValue = current - 1
sessionCounts.set(sessionId, nextValue)
return nextValue
}
function clearQuestionSessionPendingCounts(instanceId: string): void {
const sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) return
for (const sessionId of sessionCounts.keys()) {
setSessionPendingQuestion(instanceId, sessionId, false)
}
questionSessionCounts.delete(instanceId)
}
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void { function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
let inserted = false let inserted = false
@@ -485,13 +667,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
return return
} }
setActivePermissionId((prev) => { recomputeActiveInterruption(instanceId)
const next = new Map(prev)
if (!next.get(instanceId)) {
next.set(instanceId, permission.id)
}
return next
})
const sessionId = getPermissionSessionId(permission) const sessionId = getPermissionSessionId(permission)
if (sessionId) { if (sessionId) {
@@ -526,15 +702,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const updatedQueue = getPermissionQueue(instanceId) const updatedQueue = getPermissionQueue(instanceId)
setActivePermissionId((prev) => { recomputeActiveInterruption(instanceId)
const next = new Map(prev)
const activeId = next.get(instanceId)
if (activeId === permissionId) {
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
next.set(instanceId, nextPermission?.id ?? null)
}
return next
})
const removed = removedPermission const removed = removedPermission
if (removed) { if (removed) {
@@ -558,16 +726,140 @@ function clearPermissionQueue(instanceId: string): void {
return next return next
}) })
clearSessionPendingCounts(instanceId) clearSessionPendingCounts(instanceId)
recomputeActiveInterruption(instanceId)
} }
function addQuestionToQueue(instanceId: string, request: QuestionRequest): void {
let inserted = false
setQuestionQueues((prev) => {
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
setActivePermissionId((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instanceId, permissionId) const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
if (queue.some((q) => q.id === request.id)) {
return next
}
ensureQuestionEnqueuedAt(request)
const updatedQueue = [...queue, request].sort((a, b) => {
return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b)
})
next.set(instanceId, updatedQueue)
inserted = true
return next return next
}) })
if (!inserted) {
return
}
recomputeActiveInterruption(instanceId)
const sessionId = getQuestionSessionId(request)
if (sessionId) {
incrementQuestionSessionPendingCount(instanceId, sessionId)
setSessionPendingQuestion(instanceId, sessionId, true)
}
}
function removeQuestionFromQueue(instanceId: string, requestId: string): void {
const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId))
setQuestionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
const filtered = queue.filter((item) => item.id !== requestId)
if (filtered.length > 0) {
next.set(instanceId, filtered)
} else {
next.delete(instanceId)
}
return next
})
questionEnqueuedAt.delete(requestId)
recomputeActiveInterruption(instanceId)
if (removedSessionId) {
const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId)
setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0)
}
}
function clearQuestionQueue(instanceId: string): void {
for (const request of getQuestionQueue(instanceId)) {
questionEnqueuedAt.delete(request.id)
}
setQuestionQueues((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
setActiveQuestionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
clearQuestionSessionPendingCounts(instanceId)
recomputeActiveInterruption(instanceId)
}
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId })
}
function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void {
setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId })
}
async function sendQuestionReply(
instanceId: string,
_sessionId: string,
requestId: string,
answers: string[][],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
throw new Error("Instance not ready")
}
try {
await requestData(
instance.client.question.reply({
requestID: requestId,
answers,
}),
"question.reply",
)
removeQuestionFromQueue(instanceId, requestId)
} catch (error) {
log.error("Failed to send question reply", error)
throw error
}
}
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
throw new Error("Instance not ready")
}
try {
await requestData(
instance.client.question.reject({
requestID: requestId,
}),
"question.reject",
)
removeQuestionFromQueue(instanceId, requestId)
} catch (error) {
log.error("Failed to send question reject", error)
throw error
}
} }
async function sendPermissionResponse( async function sendPermissionResponse(
@@ -655,7 +947,7 @@ export {
getInstanceLogs, getInstanceLogs,
isInstanceLogStreaming, isInstanceLogStreaming,
setInstanceLogStreaming, setInstanceLogStreaming,
// Permission management // Permission + question management
permissionQueues, permissionQueues,
activePermissionId, activePermissionId,
getPermissionQueue, getPermissionQueue,
@@ -665,6 +957,18 @@ export {
clearPermissionQueue, clearPermissionQueue,
sendPermissionResponse, sendPermissionResponse,
setActivePermissionIdForInstance, setActivePermissionIdForInstance,
questionQueues,
activeQuestionId,
activeInterruption,
getQuestionQueue,
getQuestionQueueLength,
getQuestionEnqueuedAtForInstance,
addQuestionToQueue,
removeQuestionFromQueue,
clearQuestionQueue,
sendQuestionReply,
sendQuestionReject,
setActiveQuestionIdForInstance,
disconnectedInstance, disconnectedInstance,
acknowledgeDisconnectedInstance, acknowledgeDisconnectedInstance,
fetchLspStatus, fetchLspStatus,

View File

@@ -1,5 +1,7 @@
import type { PermissionRequestLike } from "../../types/permission" import type { PermissionRequestLike } from "../../types/permission"
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission" import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
import type { QuestionRequest } from "../../types/question"
import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus" import { messageStoreBus } from "./bus"
@@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st
} }
} }
function extractQuestionMessageId(request: QuestionRequest): string | undefined {
return getQuestionMessageId(request)
}
function extractQuestionCallId(request: QuestionRequest): string | undefined {
return getQuestionCallId(request)
}
export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void {
if (!request) return
const store = messageStoreBus.getOrCreate(instanceId)
const messageId = extractQuestionMessageId(request)
let partId: string | undefined = undefined
const callId = extractQuestionCallId(request)
if (callId) {
partId = resolvePartIdFromCallId(store, messageId, callId)
}
store.upsertQuestion({
request,
messageId,
partId,
enqueuedAt: (request as any).time?.created ?? Date.now(),
})
}
export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void {
const store = messageStoreBus.getOrCreate(instanceId)
const pending = store.state.questions.queue
if (!pending || pending.length === 0) return
for (const entry of pending) {
if (!entry || entry.partId) continue
const request = entry.request
if (!request) continue
const questionSessionId = request.sessionID
if (sessionId && questionSessionId && questionSessionId !== sessionId) {
continue
}
const messageId = entry.messageId ?? extractQuestionMessageId(request)
const callId = extractQuestionCallId(request)
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
if (!resolvedPartId) continue
store.upsertQuestion({
...entry,
messageId,
partId: resolvedPartId,
})
}
}
export function removeQuestionV2(instanceId: string, requestId: string): void {
if (!requestId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removeQuestion(requestId)
}
export function removePermissionV2(instanceId: string, permissionId: string): void { export function removePermissionV2(instanceId: string, permissionId: string): void {
if (!permissionId) return if (!permissionId) return
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -12,6 +12,7 @@ import type {
PartUpdateInput, PartUpdateInput,
PendingPartEntry, PendingPartEntry,
PermissionEntry, PermissionEntry,
QuestionEntry,
ReplaceMessageIdOptions, ReplaceMessageIdOptions,
ScrollSnapshot, ScrollSnapshot,
SessionRecord, SessionRecord,
@@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState {
active: null, active: null,
byMessage: {}, byMessage: {},
}, },
questions: {
queue: [],
active: null,
byMessage: {},
},
usage: {}, usage: {},
scrollState: {}, scrollState: {},
latestTodos: {}, latestTodos: {},
@@ -193,6 +199,9 @@ export interface InstanceMessageStore {
upsertPermission: (entry: PermissionEntry) => void upsertPermission: (entry: PermissionEntry) => void
removePermission: (permissionId: string) => void removePermission: (permissionId: string) => void
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
upsertQuestion: (entry: QuestionEntry) => void
removeQuestion: (requestId: string) => void
getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
@@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}) })
} }
const questionMap = state.questions.byMessage[options.oldId]
if (questionMap) {
setState("questions", "byMessage", options.newId, questionMap)
setState("questions", (prev) => {
const next = { ...prev }
const nextByMessage = { ...next.byMessage }
delete nextByMessage[options.oldId]
next.byMessage = nextByMessage
return next
})
}
const pending = state.pendingParts[options.oldId] const pending = state.pendingParts[options.oldId]
if (pending) { if (pending) {
setState("pendingParts", options.newId, pending) setState("pendingParts", options.newId, pending)
@@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return { entry, active } return { entry, active }
} }
function upsertQuestion(entry: QuestionEntry) {
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
setState(
"questions",
produce((draft) => {
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
draft.byMessage[messageKey][partKey] = entry
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
if (existingIndex === -1) {
draft.queue.push(entry)
} else {
draft.queue[existingIndex] = entry
}
if (!draft.active || draft.active.request.id === entry.request.id) {
draft.active = entry
}
}),
)
}
function removeQuestion(requestId: string) {
setState(
"questions",
produce((draft) => {
draft.queue = draft.queue.filter((item) => item.request.id !== requestId)
if (draft.active?.request.id === requestId) {
draft.active = draft.queue[0] ?? null
}
Object.keys(draft.byMessage).forEach((messageKey) => {
const partEntries = draft.byMessage[messageKey]
Object.keys(partEntries).forEach((partKey) => {
if (partEntries[partKey].request.id === requestId) {
delete partEntries[partKey]
}
})
if (Object.keys(partEntries).length === 0) {
delete draft.byMessage[messageKey]
}
})
}),
)
}
function getQuestionState(messageId?: string, partId?: string) {
const messageKey = messageId ?? "__global__"
const partKey = partId ?? "__global__"
const entry = state.questions.byMessage[messageKey]?.[partKey]
if (!entry) return null
const active = state.questions.active?.request.id === entry.request.id
return { entry, active }
}
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) { function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
const session = state.sessions[sessionId] const session = state.sessions[sessionId]
if (!session) return if (!session) return
@@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next return next
}) })
setState("questions", "byMessage", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
withUsageState(sessionId, (draft) => { withUsageState(sessionId, (draft) => {
removedIds.forEach((id) => removeUsageEntry(draft, id)) removedIds.forEach((id) => removeUsageEntry(draft, id))
}) })
@@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next return next
}) })
setState("questions", "byMessage", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
setState("usage", (prev) => { setState("usage", (prev) => {
const next = { ...prev } const next = { ...prev }
delete next[sessionId] delete next[sessionId]
@@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
replaceMessageId, replaceMessageId,
setMessageInfo, setMessageInfo,
getMessageInfo, getMessageInfo,
upsertPermission, upsertPermission,
removePermission, removePermission,
getPermissionState, getPermissionState,
upsertQuestion,
removeQuestion,
getQuestionState,
setSessionRevert, setSessionRevert,
getSessionRevert, getSessionRevert,
rebuildUsage, rebuildUsage,

Some files were not shown because too many files have changed in this diff Show More