Compare commits
22 Commits
pr-162
...
v0.10.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f6c8523c0 | ||
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
c7195469bd | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 | ||
|
|
ffe991bbe4 | ||
|
|
3047a1e602 | ||
|
|
e6c568988a | ||
|
|
45fab91e7f | ||
|
|
d3484ec3af | ||
|
|
cb0d601b09 | ||
|
|
9ea4f6b5ef | ||
|
|
bf9ee76de5 | ||
|
|
67a530a83b | ||
|
|
612ec6af1b |
35
.github/workflows/dev-release.yml
vendored
35
.github/workflows/dev-release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Dev CI
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,12 +7,35 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-prerelease
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dev-ci:
|
prepare:
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||||
|
steps:
|
||||||
|
- name: Compute version suffix
|
||||||
|
id: vars
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SHA8="${GITHUB_SHA::8}"
|
||||||
|
DATE=$(date -u +%Y%m%d)
|
||||||
|
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
prerelease:
|
||||||
|
needs: prepare
|
||||||
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
upload: false
|
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||||
set_versions: false
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
|
dist_tag: latest
|
||||||
|
prerelease: true
|
||||||
|
release_ui: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
34
.github/workflows/manual-npm-publish.yml
vendored
34
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,6 +12,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
type: string
|
||||||
|
package_name:
|
||||||
|
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||||
|
required: false
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -21,6 +26,13 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: dev
|
default: dev
|
||||||
|
package_name:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
secrets:
|
||||||
|
NPM_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -51,7 +63,7 @@ jobs:
|
|||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
- name: Build server package (includes UI bundling)
|
- name: Build server package (includes UI bundling)
|
||||||
run: npm run build --workspace @neuralnomads/codenomad
|
run: npm run build --workspace packages/server
|
||||||
|
|
||||||
- name: Set publish metadata
|
- name: Set publish metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -62,13 +74,31 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||||
|
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Bump package version for publish
|
- name: Bump package version for publish
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Set server package name for publish
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||||
|
|
||||||
- name: Publish server package with provenance
|
- name: Publish server package with provenance
|
||||||
env:
|
env:
|
||||||
|
# Optional: when present, npm will use token auth.
|
||||||
|
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
set -euo pipefail
|
||||||
|
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||||
|
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
|
||||||
|
unset NODE_AUTH_TOKEN
|
||||||
|
else
|
||||||
|
echo "Using NPM_TOKEN authentication"
|
||||||
|
fi
|
||||||
|
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
|||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
|
npm_package_name: "@neuralnomads/codenomad"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
24
.github/workflows/reusable-release.yml
vendored
24
.github/workflows/reusable-release.yml
vendored
@@ -13,6 +13,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
type: string
|
||||||
|
npm_package_name:
|
||||||
|
description: "npm package name to publish (defaults to server package name)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: "Create GitHub prerelease"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
release_ui:
|
||||||
|
description: "Publish remote UI + manifest"
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -53,11 +68,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.versions.outputs.tag }}
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||||
run: |
|
run: |
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists"
|
echo "Release $TAG already exists"
|
||||||
else
|
else
|
||||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
@@ -71,6 +91,7 @@ jobs:
|
|||||||
|
|
||||||
release-ui:
|
release-ui:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
|
if: ${{ inputs.release_ui }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
@@ -84,4 +105,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -44,13 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
For dev version
|
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||||
|
- [packages/server/README.md](packages/server/README.md)
|
||||||
|
|
||||||
|
To see all available options:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad@dev --launch
|
npx @neuralnomads/codenomad --help
|
||||||
```
|
```
|
||||||
|
|
||||||
This command starts the server and opens the web client in your default browser.
|
### 🧪 Dev Releases
|
||||||
|
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -11879,6 +11879,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11974,7 +11989,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -12017,6 +12033,7 @@
|
|||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { EventEmitter } from "events"
|
|||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
@@ -39,6 +40,36 @@ interface CliEntryResolution {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
|
function isYamlPath(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonPath(filePath: string): boolean {
|
||||||
|
return filePath.toLowerCase().endsWith(".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
||||||
|
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
||||||
|
const resolved = resolveConfigPath(target)
|
||||||
|
|
||||||
|
if (isYamlPath(resolved)) {
|
||||||
|
const baseDir = path.dirname(resolved)
|
||||||
|
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonPath(resolved)) {
|
||||||
|
const baseDir = path.dirname(resolved)
|
||||||
|
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as directory.
|
||||||
|
return {
|
||||||
|
configYamlPath: path.join(resolved, "config.yaml"),
|
||||||
|
legacyJsonPath: path.join(resolved, "config.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConfigPath(configPath?: string): string {
|
function resolveConfigPath(configPath?: string): string {
|
||||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||||
if (target.startsWith("~/")) {
|
if (target.startsWith("~/")) {
|
||||||
@@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string {
|
|||||||
|
|
||||||
function readListeningModeFromConfig(): ListeningMode {
|
function readListeningModeFromConfig(): ListeningMode {
|
||||||
try {
|
try {
|
||||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||||
if (!existsSync(configPath)) return "local"
|
|
||||||
const content = readFileSync(configPath, "utf-8")
|
let parsed: any = null
|
||||||
const parsed = JSON.parse(content)
|
if (existsSync(configYamlPath)) {
|
||||||
|
const content = readFileSync(configYamlPath, "utf-8")
|
||||||
|
parsed = parseYaml(content)
|
||||||
|
} else if (existsSync(legacyJsonPath)) {
|
||||||
|
const content = readFileSync(legacyJsonPath, "utf-8")
|
||||||
|
parsed = JSON.parse(content)
|
||||||
|
} else {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
const mode = parsed?.preferences?.listeningMode
|
const mode = parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neuralnomads/codenomad": "file:../server",
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
"@codenomad/ui": "file:../ui"
|
"@codenomad/ui": "file:../ui",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
|
|||||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
public/
|
public/
|
||||||
|
|
||||||
|
# Local developer config (may contain secrets)
|
||||||
|
config-*.json
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To list all CLI options:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @neuralnomads/codenomad --help
|
||||||
|
```
|
||||||
|
|
||||||
On startup, CodeNomad prints two URLs:
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
- `Local Connection URL : ...` (used by desktop shells)
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Install Locally (per-project)
|
||||||
|
If you prefer to install CodeNomad into a project and run the local binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @neuralnomads/codenomad
|
||||||
|
npx codenomad --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||||
|
|
||||||
### Common Flags
|
### Common Flags
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||||
|
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||||
|
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||||
|
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||||
|
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||||
|
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
||||||
|
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||||
|
|
||||||
|
### Dev Releases (Advanced)
|
||||||
|
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
These environment variables control how CodeNomad checks for dev updates:
|
||||||
|
|
||||||
|
| Env Variable | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||||
|
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||||
|
|
||||||
### HTTP vs HTTPS
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export interface ServerMeta {
|
|||||||
serverVersion?: string
|
serverVersion?: string
|
||||||
ui?: UiMeta
|
ui?: UiMeta
|
||||||
support?: SupportMeta
|
support?: SupportMeta
|
||||||
|
/** Optional update info (dev channel only). */
|
||||||
|
update?: LatestReleaseInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|||||||
78
packages/server/src/config/location.ts
Normal file
78
packages/server/src/config/location.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export interface ConfigLocation {
|
||||||
|
/** Resolved absolute base directory containing all persisted server data. */
|
||||||
|
baseDir: string
|
||||||
|
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
|
||||||
|
configYamlPath: string
|
||||||
|
/** Canonical YAML state file path (always in baseDir). */
|
||||||
|
stateYamlPath: string
|
||||||
|
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
|
||||||
|
legacyJsonPath: string
|
||||||
|
/** Directory for per-instance persisted data (chat history etc.). */
|
||||||
|
instancesDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(inputPath: string): string {
|
||||||
|
if (inputPath.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), inputPath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlPath(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonPath(filePath: string): boolean {
|
||||||
|
return filePath.toLowerCase().endsWith(".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
|
||||||
|
*
|
||||||
|
* Supported inputs:
|
||||||
|
* - Directory: "~/.config/codenomad"
|
||||||
|
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
|
||||||
|
* - Legacy JSON file: "~/.config/codenomad/config.json"
|
||||||
|
*/
|
||||||
|
export function resolveConfigLocation(raw: string): ConfigLocation {
|
||||||
|
const trimmed = (raw ?? "").trim()
|
||||||
|
const fallback = "~/.config/codenomad/config.json"
|
||||||
|
const input = trimmed.length > 0 ? trimmed : fallback
|
||||||
|
|
||||||
|
const resolvedInput = resolvePath(input)
|
||||||
|
|
||||||
|
if (isYamlPath(resolvedInput)) {
|
||||||
|
const baseDir = path.dirname(resolvedInput)
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: resolvedInput,
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonPath(resolvedInput)) {
|
||||||
|
const baseDir = path.dirname(resolvedInput)
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: resolvedInput,
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = resolvedInput
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({
|
|||||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||||
|
|
||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z
|
||||||
|
.object({
|
||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
@@ -31,7 +32,9 @@ const PreferencesSchema = z.object({
|
|||||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||||
notifyOnNeedsInput: z.boolean().default(true),
|
notifyOnNeedsInput: z.boolean().default(true),
|
||||||
notifyOnIdle: z.boolean().default(true),
|
notifyOnIdle: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
// Preserve unknown preference keys so newer configs survive older binaries.
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ConfigFileSchema = z.object({
|
const ConfigFileSchema = z
|
||||||
preferences: PreferencesSchema.default({}),
|
.object({
|
||||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
preferences: PreferencesSchema.default({}),
|
||||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
})
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
// Preserve unknown top-level keys so optional future features survive downgrades.
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
|
||||||
|
const ConfigYamlSchema = z
|
||||||
|
.object({
|
||||||
|
preferences: PreferencesSchema.default({}),
|
||||||
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
|
||||||
|
const StateFileSchema = z
|
||||||
|
.object({
|
||||||
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||||
|
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||||
|
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ModelPreferenceSchema,
|
ModelPreferenceSchema,
|
||||||
@@ -62,7 +86,11 @@ export {
|
|||||||
RecentFolderSchema,
|
RecentFolderSchema,
|
||||||
OpenCodeBinarySchema,
|
OpenCodeBinarySchema,
|
||||||
ConfigFileSchema,
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
StateFileSchema,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||||
@@ -72,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
|||||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||||
|
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
|
||||||
|
export type StateFile = z.infer<typeof StateFileSchema>
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
import {
|
||||||
|
ConfigFile,
|
||||||
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
|
StateFile,
|
||||||
|
StateFileSchema,
|
||||||
|
} from "./schema"
|
||||||
|
import type { ConfigLocation } from "./location"
|
||||||
|
|
||||||
export class ConfigStore {
|
export class ConfigStore {
|
||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
|
private state: StateFile = DEFAULT_STATE
|
||||||
private loaded = false
|
private loaded = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configPath: string,
|
private readonly location: ConfigLocation,
|
||||||
private readonly eventBus: EventBus | undefined,
|
private readonly eventBus: EventBus | undefined,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
@@ -20,19 +32,37 @@ export class ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolved = this.resolvePath(this.configPath)
|
const configYamlPath = this.location.configYamlPath
|
||||||
if (fs.existsSync(resolved)) {
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
const content = fs.readFileSync(resolved, "utf-8")
|
const legacyJsonPath = this.location.legacyJsonPath
|
||||||
const parsed = JSON.parse(content)
|
|
||||||
this.cache = ConfigFileSchema.parse(parsed)
|
if (fs.existsSync(configYamlPath)) {
|
||||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
||||||
|
const stateDoc = fs.existsSync(stateYamlPath)
|
||||||
|
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
||||||
|
: DEFAULT_STATE
|
||||||
|
|
||||||
|
this.state = stateDoc
|
||||||
|
this.cache = this.mergeDocs(configDoc, stateDoc)
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
||||||
|
} else if (fs.existsSync(legacyJsonPath)) {
|
||||||
|
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
||||||
|
this.state = migrated.state
|
||||||
|
this.cache = migrated.config
|
||||||
} else {
|
} else {
|
||||||
this.cache = DEFAULT_CONFIG
|
// Fresh install: write defaults.
|
||||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
|
this.persist()
|
||||||
|
this.logger.debug(
|
||||||
|
{ configYamlPath, stateYamlPath },
|
||||||
|
"No config files found, created default YAML config/state",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
||||||
this.cache = DEFAULT_CONFIG
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
@@ -48,9 +78,30 @@ export class ConfigStore {
|
|||||||
this.commit(validated)
|
this.commit(validated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a merge-patch update to the current config.
|
||||||
|
* - Missing keys are preserved.
|
||||||
|
* - Object values are merged recursively.
|
||||||
|
* - Explicit `null` deletes keys.
|
||||||
|
* - Arrays are replaced.
|
||||||
|
*/
|
||||||
|
mergePatch(patch: unknown) {
|
||||||
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||||
|
throw new Error("Config patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current as any, patch as any)
|
||||||
|
const validated = ConfigFileSchema.parse(next)
|
||||||
|
this.commit(validated)
|
||||||
|
}
|
||||||
|
|
||||||
private commit(next: ConfigFile) {
|
private commit(next: ConfigFile) {
|
||||||
this.cache = next
|
this.cache = next
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
recentFolders: next.recentFolders,
|
||||||
|
}
|
||||||
this.persist()
|
this.persist()
|
||||||
const published = Boolean(this.eventBus)
|
const published = Boolean(this.eventBus)
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||||
@@ -60,19 +111,134 @@ export class ConfigStore {
|
|||||||
|
|
||||||
private persist() {
|
private persist() {
|
||||||
try {
|
try {
|
||||||
const resolved = this.resolvePath(this.configPath)
|
const configYamlPath = this.location.configYamlPath
|
||||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
|
||||||
this.logger.debug({ resolved }, "Persisted config file")
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
||||||
|
|
||||||
|
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
||||||
|
const stateYaml = stringifyYaml(this.state as any)
|
||||||
|
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
||||||
|
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error }, "Failed to persist config")
|
this.logger.warn({ err: error }, "Failed to persist config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePath(filePath: string) {
|
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
||||||
if (filePath.startsWith("~/")) {
|
const merged = {
|
||||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
...(configDoc as any),
|
||||||
|
// State wins for recent folders.
|
||||||
|
recentFolders: stateDoc.recentFolders ?? [],
|
||||||
}
|
}
|
||||||
return path.resolve(filePath)
|
|
||||||
|
return ConfigFileSchema.parse(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readYamlFile<T>(
|
||||||
|
filePath: string,
|
||||||
|
fallback: T,
|
||||||
|
schema: { parse: (value: unknown) => T },
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
return schema.parse(parsed ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
|
||||||
|
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
const legacy = ConfigFileSchema.parse(parsed)
|
||||||
|
|
||||||
|
const state: StateFile = StateFileSchema.parse({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
recentFolders: legacy.recentFolders ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
||||||
|
|
||||||
|
// Persist YAML docs first, then move legacy aside.
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
||||||
|
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bakPath = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bakPath)
|
||||||
|
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: merged, state }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
||||||
|
const clone: Record<string, unknown> = { ...(config as any) }
|
||||||
|
delete clone.recentFolders
|
||||||
|
return clone as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMergePatch(current: any, patch: any): any {
|
||||||
|
// RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = isPlainObject(current) ? { ...current } : {}
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value) && isPlainObject(base[key])) {
|
||||||
|
base[key] = applyMergePatch(base[key], value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrays and scalars replace.
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(legacyJsonPath: string): string {
|
||||||
|
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
||||||
|
const preferred = `${base}.json.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${base}.json.bak.${Date.now()}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createRequire } from "module"
|
|||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
import { ConfigStore } from "./config/store"
|
import { ConfigStore } from "./config/store"
|
||||||
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { BinaryRegistry } from "./config/binaries"
|
import { BinaryRegistry } from "./config/binaries"
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
@@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
|
|||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePath(filePath: string) {
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
|
||||||
}
|
|
||||||
return path.resolve(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
function programHasArg(argv: string[], flag: string): boolean {
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
return argv.includes(flag)
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
@@ -245,7 +240,8 @@ async function main() {
|
|||||||
|
|
||||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
const configDir = path.dirname(resolvePath(options.configPath))
|
const configLocation = resolveConfigLocation(options.configPath)
|
||||||
|
const configDir = configLocation.baseDir
|
||||||
|
|
||||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||||
@@ -266,7 +262,7 @@ async function main() {
|
|||||||
|
|
||||||
const authManager = new AuthManager(
|
const authManager = new AuthManager(
|
||||||
{
|
{
|
||||||
configPath: options.configPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
@@ -295,7 +291,16 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||||
|
|
||||||
|
// Eagerly load config at boot so migrations run immediately
|
||||||
|
// (instead of waiting for the first /api/config request).
|
||||||
|
try {
|
||||||
|
configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
||||||
|
}
|
||||||
|
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
@@ -307,7 +312,7 @@ async function main() {
|
|||||||
nodeExtraCaCertsPath,
|
nodeExtraCaCertsPath,
|
||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore()
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -344,6 +349,21 @@ async function main() {
|
|||||||
minServerVersion: uiResolution.minServerVersion,
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
|
||||||
|
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
|
||||||
|
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
|
||||||
|
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
|
||||||
|
const devReleaseMonitor = enableDevUpdateChecks
|
||||||
|
? startDevReleaseMonitor({
|
||||||
|
currentVersion: packageJson.version,
|
||||||
|
repo: githubRepo,
|
||||||
|
logger: logger.child({ component: "updates" }),
|
||||||
|
onUpdate: (release) => {
|
||||||
|
serverMeta.update = release
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
if (uiResolution.uiDevServerUrl && options.https) {
|
if (uiResolution.uiDevServerUrl && options.https) {
|
||||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,8 @@ async function main() {
|
|||||||
|
|
||||||
// no-op: remote UI manifest replaces GitHub release monitor
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
|
devReleaseMonitor?.stop()
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { fetch } from "undici"
|
||||||
|
import type { LatestReleaseInfo } from "../api-types"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
|
||||||
|
|
||||||
|
interface DevReleaseMonitorOptions {
|
||||||
|
/** Current running server version (from package.json). */
|
||||||
|
currentVersion: string
|
||||||
|
/** GitHub repo in the form "owner/name". */
|
||||||
|
repo: string
|
||||||
|
logger: Logger
|
||||||
|
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||||
|
pollIntervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubReleaseListItem {
|
||||||
|
tag_name?: string
|
||||||
|
name?: string
|
||||||
|
html_url?: string
|
||||||
|
body?: string
|
||||||
|
published_at?: string
|
||||||
|
created_at?: string
|
||||||
|
prerelease?: boolean
|
||||||
|
draft?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevReleaseMonitor {
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
|
||||||
|
let stopped = false
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const pollIntervalMs =
|
||||||
|
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
|
||||||
|
? (options.pollIntervalMs as number)
|
||||||
|
: DEFAULT_POLL_INTERVAL_MS
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (stopped) return
|
||||||
|
try {
|
||||||
|
const release = await fetchLatestPrerelease({
|
||||||
|
repo: options.repo,
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
})
|
||||||
|
options.onUpdate(release)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh()
|
||||||
|
timer = setInterval(() => void refresh(), pollIntervalMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestPrerelease(args: {
|
||||||
|
repo: string
|
||||||
|
currentVersion: string
|
||||||
|
}): Promise<LatestReleaseInfo | null> {
|
||||||
|
const normalizedRepo = args.repo.trim()
|
||||||
|
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
|
||||||
|
throw new Error(`Invalid GitHub repo: ${args.repo}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub releases API responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (await response.json()) as GithubReleaseListItem[]
|
||||||
|
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
|
||||||
|
if (!latest) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = latest.tag_name || latest.name
|
||||||
|
if (!tag) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVersion = stripTagPrefix(tag)
|
||||||
|
if (!normalizedVersion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: normalizedVersion,
|
||||||
|
tag,
|
||||||
|
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
|
||||||
|
channel: "dev",
|
||||||
|
publishedAt: latest.published_at ?? latest.created_at,
|
||||||
|
notes: latest.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function compareVersionStrings(a: string, b: string): number {
|
||||||
|
const left = parseVersion(a)
|
||||||
|
const right = parseVersion(b)
|
||||||
|
return compareVersions(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||||
const response = await fetch(RELEASES_API_URL, {
|
const response = await fetch(RELEASES_API_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTagPrefix(tag: string | undefined): string | null {
|
export function stripTagPrefix(tag: string | undefined): string | null {
|
||||||
if (!tag) return null
|
if (!tag) return null
|
||||||
const trimmed = tag.trim()
|
const trimmed = tag.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
|
|||||||
|
|
||||||
function parseVersion(value: string): NormalizedVersion {
|
function parseVersion(value: string): NormalizedVersion {
|
||||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||||
const [core, prerelease = null] = normalized.split("-", 2)
|
const dashIndex = normalized.indexOf("-")
|
||||||
|
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
|
||||||
|
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
|
||||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||||
const parsed = Number.parseInt(segment, 10)
|
const parsed = Number.parseInt(segment, 10)
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { ConfigStore } from "../../config/store"
|
import { ConfigStore } from "../../config/store"
|
||||||
import { BinaryRegistry } from "../../config/binaries"
|
import { BinaryRegistry } from "../../config/binaries"
|
||||||
import { ConfigFileSchema } from "../../config/schema"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
configStore: ConfigStore
|
configStore: ConfigStore
|
||||||
@@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({
|
|||||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/config/app", async () => deps.configStore.get())
|
app.get("/api/config/app", async () => deps.configStore.get())
|
||||||
|
|
||||||
app.put("/api/config/app", async (request) => {
|
app.put("/api/config/app", async (request, reply) => {
|
||||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
// Backwards compatible: treat PUT as a merge-patch update.
|
||||||
deps.configStore.replace(body)
|
try {
|
||||||
return deps.configStore.get()
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/config/app", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/api/config/binaries", async () => {
|
app.get("/api/config/binaries", async () => {
|
||||||
|
|||||||
20
packages/tauri-app/Cargo.lock
generated
20
packages/tauri-app/Cargo.lock
generated
@@ -636,6 +636,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
@@ -3894,6 +3895,19 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.12.1",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -5015,6 +5029,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
|
|||||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
|||||||
@@ -145,12 +145,33 @@ struct AppConfig {
|
|||||||
preferences: Option<PreferencesConfig>,
|
preferences: Option<PreferencesConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_path() -> PathBuf {
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
let raw = env::var("CLI_CONFIG")
|
let raw = env::var("CLI_CONFIG")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||||
expand_home(&raw)
|
|
||||||
|
let expanded = expand_home(&raw);
|
||||||
|
let lower = raw.trim().to_lowercase();
|
||||||
|
|
||||||
|
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (expanded, base.join("config.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.ends_with(".json") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (base.join("config.yaml"), expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as directory.
|
||||||
|
(expanded.join("config.yaml"), expanded.join("config.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_home(path: &str) -> PathBuf {
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
@@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_listening_mode() -> String {
|
fn resolve_listening_mode() -> String {
|
||||||
let path = resolve_config_path();
|
let (yaml_path, json_path) = resolve_config_locations();
|
||||||
if let Ok(content) = fs::read_to_string(path) {
|
|
||||||
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
|
if let Some(mode) = config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
{
|
||||||
|
if mode == "local" {
|
||||||
|
return "local".to_string();
|
||||||
|
}
|
||||||
|
if mode == "all" {
|
||||||
|
return "all".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback.
|
||||||
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
if let Some(mode) = config
|
if let Some(mode) = config
|
||||||
.preferences
|
.preferences
|
||||||
@@ -260,7 +300,14 @@ impl CliProcessManager {
|
|||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.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, token_arc, 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;
|
||||||
@@ -369,7 +416,9 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
if !supports_user_shell() {
|
if !supports_user_shell() {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +469,6 @@ impl CliProcessManager {
|
|||||||
let token_clone = bootstrap_token.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()
|
||||||
@@ -433,10 +481,24 @@ 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, &token_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, &token_clone);
|
Self::process_stream(
|
||||||
|
reader,
|
||||||
|
"stderr",
|
||||||
|
&app_clone,
|
||||||
|
&status_clone,
|
||||||
|
&ready_clone,
|
||||||
|
&token_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,8 +571,14 @@ impl CliProcessManager {
|
|||||||
if locked.error.is_none() {
|
if locked.error.is_none() {
|
||||||
locked.error = err_msg.clone();
|
locked.error = err_msg.clone();
|
||||||
}
|
}
|
||||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
log_line(&format!(
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
"cli process exited before ready: {:?}",
|
||||||
|
locked.error
|
||||||
|
));
|
||||||
|
let _ = app_clone.emit(
|
||||||
|
"cli:error",
|
||||||
|
json!({"message": locked.error.clone().unwrap_or_default()}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
locked.state = CliState::Stopped;
|
locked.state = CliState::Stopped;
|
||||||
log_line("cli process stopped cleanly");
|
log_line("cli process stopped cleanly");
|
||||||
@@ -574,13 +642,25 @@ 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, bootstrap_token, format!("http://localhost:{port}"));
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{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, bootstrap_token, format!("http://localhost:{}", port));
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{}", port),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,7 +799,12 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||||
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
let mut args = vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--host".to_string(),
|
||||||
|
host.to_string(),
|
||||||
|
"--generate-token".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
@@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe()
|
std::env::current_exe().ok().and_then(|ex| {
|
||||||
.ok()
|
ex.parent()
|
||||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
let base = workspace_root();
|
let base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
base.as_ref()
|
||||||
|
.map(|p| p.join("packages/server/dist/index.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||||
];
|
];
|
||||||
@@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
candidates.push(Some(
|
||||||
|
resources.join("resources/server/dist/server/index.js"),
|
||||||
|
));
|
||||||
|
|
||||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
@@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
fn build_shell_command_string(
|
||||||
|
entry: &CliEntry,
|
||||||
|
cli_args: &[String],
|
||||||
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
@@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String {
|
|||||||
"''".to_string()
|
"''".to_string()
|
||||||
} else if !input
|
} else if !input
|
||||||
.chars()
|
.chars()
|
||||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
||||||
{
|
{
|
||||||
input.to_string()
|
input.to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
@@ -80,6 +81,13 @@ const App: Component = () => {
|
|||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const shouldShow =
|
||||||
|
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||||
|
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||||
|
})
|
||||||
|
|
||||||
const updateInstanceTabBarHeight = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
@@ -293,6 +301,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const groupedCommandList = () => processedCommands().groups
|
const groupedCommandList = () => processedCommands().groups
|
||||||
const orderedCommands = () => processedCommands().ordered
|
const orderedCommands = () => processedCommands().ordered
|
||||||
|
|
||||||
|
const isCommandDisabled = (command: Command) => {
|
||||||
|
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
||||||
|
}
|
||||||
const selectedIndex = createMemo(() => {
|
const selectedIndex = createMemo(() => {
|
||||||
const ordered = orderedCommands()
|
const ordered = orderedCommands()
|
||||||
if (ordered.length === 0) return -1
|
if (ordered.length === 0) return -1
|
||||||
@@ -141,7 +145,8 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const currentId = selectedCommandId()
|
const currentId = selectedCommandId()
|
||||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||||
setSelectedCommandId(ordered[0].id)
|
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
||||||
|
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
if (index < 0 || index >= ordered.length) return
|
if (index < 0 || index >= ordered.length) return
|
||||||
const command = ordered[index]
|
const command = ordered[index]
|
||||||
if (!command) return
|
if (!command) return
|
||||||
|
if (isCommandDisabled(command)) return
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommandClick(command: Command) {
|
function handleCommandClick(command: Command) {
|
||||||
|
if (isCommandDisabled(command)) return
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
const commandIndex = group.startIndex + localIndex()
|
const commandIndex = group.startIndex + localIndex()
|
||||||
|
const disabled = isCommandDisabled(command)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-command-index={commandIndex}
|
data-command-index={commandIndex}
|
||||||
onClick={() => handleCommandClick(command)}
|
onClick={() => handleCommandClick(command)}
|
||||||
|
disabled={disabled}
|
||||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (event.movementX === 0 && event.movementY === 0) return
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer">
|
<div class="panel-footer keyboard-hints">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -573,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -591,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" />
|
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||||
<span>{t("folderSelection.hints.browse")}</span>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
|
|||||||
interface HintRowProps {
|
interface HintRowProps {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
class?: string
|
class?: string
|
||||||
|
ariaHidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
return (
|
||||||
|
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
||||||
|
{props.children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HintRow
|
export default HintRow
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel-footer hidden sm:block">
|
<div class="panel-footer hidden sm:block keyboard-hints">
|
||||||
|
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{t("instanceShell.commandPalette.button")}
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint kbd-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint kbd-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Show, type Accessor, type Component } from "solid-js"
|
import { Show, type Accessor, type Component } from "solid-js"
|
||||||
import type { SessionThread } from "../../../stores/session-state"
|
import type { SessionThread } from "../../../stores/session-state"
|
||||||
import type { Session } from "../../../types/session"
|
import type { Session } from "../../../types/session"
|
||||||
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||||
import type { DrawerViewState } from "./types"
|
import type { DrawerViewState } from "./types"
|
||||||
|
|
||||||
import { PlusSquare, Search } from "lucide-solid"
|
import { PlusSquare, Search } from "lucide-solid"
|
||||||
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
|||||||
|
|
||||||
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 WorktreeSelector from "../../worktree-selector"
|
import WorktreeSelector from "../../worktree-selector"
|
||||||
import AgentSelector from "../../agent-selector"
|
import AgentSelector from "../../agent-selector"
|
||||||
import ModelSelector from "../../model-selector"
|
import ModelSelector from "../../model-selector"
|
||||||
@@ -166,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
|||||||
|
|
||||||
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||||
|
|
||||||
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
<KeyboardHint
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
class="session-sidebar-selector-hints"
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
ariaHidden={true}
|
||||||
<Kbd shortcut="cmd+shift+t" />
|
shortcuts={[
|
||||||
</div>
|
keyboardRegistry.get("open-agent-selector"),
|
||||||
|
keyboardRegistry.get("focus-model"),
|
||||||
|
keyboardRegistry.get("focus-variant"),
|
||||||
|
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||||
|
separator=" "
|
||||||
|
showDescription={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, JSX, For } from "solid-js"
|
import { Component, JSX, For } from "solid-js"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
|
|
||||||
interface KbdProps {
|
interface KbdProps {
|
||||||
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Kbd: Component<KbdProps> = (props) => {
|
const Kbd: Component<KbdProps> = (props) => {
|
||||||
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
if (!desktopQuery()) return null
|
||||||
|
|
||||||
const parts = () => {
|
const parts = () => {
|
||||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||||
if (!props.shortcut) return []
|
if (!props.shortcut) return []
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Component, For } from "solid-js"
|
import { Component, For } from "solid-js"
|
||||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import HintRow from "./hint-row"
|
import HintRow from "./hint-row"
|
||||||
|
|
||||||
const KeyboardHint: Component<{
|
const KeyboardHint: Component<{
|
||||||
shortcuts: KeyboardShortcut[]
|
shortcuts: KeyboardShortcut[]
|
||||||
separator?: string
|
separator?: string | null
|
||||||
showDescription?: boolean
|
showDescription?: boolean
|
||||||
|
class?: string
|
||||||
|
ariaHidden?: boolean
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
// Centralize layout gating here so call sites don't need to.
|
||||||
|
// We only show keyboard hint UI on desktop layouts.
|
||||||
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
|
||||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
|
|||||||
return parts.join("+")
|
return parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!desktopQuery()) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HintRow>
|
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
|
||||||
<For each={props.shortcuts}>
|
<For each={props.shortcuts}>
|
||||||
{(shortcut, i) => (
|
{(shortcut, i) => (
|
||||||
<>
|
<>
|
||||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
|
||||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
{t("messageListHeader.commandPalette.button")}
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
|
||||||
</li>
|
</li>
|
||||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
|
import { createSignal, Show, onMount, onCleanup, createEffect, on } 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 ExpandButton from "./expand-button"
|
||||||
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
handleDrop,
|
handleDrop,
|
||||||
syncAttachmentCounters,
|
syncAttachmentCounters,
|
||||||
handleExpandTextAttachment,
|
handleExpandTextAttachment,
|
||||||
|
handleRemoveAttachment,
|
||||||
} = usePromptAttachments({
|
} = usePromptAttachments({
|
||||||
instanceId: () => props.instanceId,
|
instanceId: () => props.instanceId,
|
||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (!attachment) return
|
if (!attachment) return
|
||||||
handleExpandTextAttachment(attachment)
|
handleExpandTextAttachment(attachment)
|
||||||
},
|
},
|
||||||
|
removeAttachment: (attachmentId: string) => {
|
||||||
|
handleRemoveAttachment(attachmentId)
|
||||||
|
},
|
||||||
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
||||||
const textarea = textareaRef
|
const textarea = textareaRef
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
setSearchQuery("")
|
setSearchQuery("")
|
||||||
|
|
||||||
const instanceId = props.instanceId
|
syncAttachmentCounters(prompt())
|
||||||
const sessionId = props.sessionId
|
|
||||||
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
|
|
||||||
syncAttachmentCounters(prompt(), currentAttachments)
|
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||||
if (!isKnownSlashCommand) {
|
if (!isKnownSlashCommand) {
|
||||||
clearAttachments(props.instanceId, props.sessionId)
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
syncAttachmentCounters("", [])
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
syncAttachmentCounters("", currentAttachments)
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
when={props.escapeInDebounce}
|
when={props.escapeInDebounce}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Attachment } from "../../types/attachment"
|
|
||||||
|
|
||||||
export function formatPastedPlaceholder(value: string | number) {
|
export function formatPastedPlaceholder(value: string | number) {
|
||||||
return `[pasted #${value}]`
|
return `[pasted #${value}]`
|
||||||
}
|
}
|
||||||
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPastedPlaceholderRegex() {
|
export function createPastedPlaceholderRegex() {
|
||||||
return /\[pasted #(\d+)\]/g
|
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImagePlaceholderRegex() {
|
export function createImagePlaceholderRegex() {
|
||||||
return /\[Image #(\d+)\]/g
|
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMentionRegex() {
|
export function createMentionRegex() {
|
||||||
return /@(\S+)/g
|
return /@(\S+)/g
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
|
||||||
export const imageDisplayCounterRegex = /Image #(\d+)/
|
export const imageDisplayCounterRegex = /Image #(\d+)/i
|
||||||
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
|
||||||
|
|
||||||
export function parseCounter(value: string) {
|
export function parseCounter(value: string) {
|
||||||
const parsed = Number.parseInt(value, 10)
|
const parsed = Number.parseInt(value, 10)
|
||||||
return Number.isNaN(parsed) ? null : parsed
|
return Number.isNaN(parsed) ? null : parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
export function findHighestAttachmentCounters(currentPrompt: string) {
|
||||||
let highestPaste = 0
|
let highestPaste = 0
|
||||||
let highestImage = 0
|
let highestImage = 0
|
||||||
|
|
||||||
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const attachment of sessionAttachments) {
|
|
||||||
if (attachment.source.type === "text") {
|
|
||||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
|
||||||
if (placeholderMatch) {
|
|
||||||
const parsed = parseCounter(placeholderMatch[1])
|
|
||||||
if (parsed !== null) {
|
|
||||||
highestPaste = Math.max(highestPaste, parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
|
||||||
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
|
||||||
if (imageMatch) {
|
|
||||||
const parsed = parseCounter(imageMatch[1])
|
|
||||||
if (parsed !== null) {
|
|
||||||
highestImage = Math.max(highestImage, parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||||
const parsed = parseCounter(match[1])
|
const parsed = parseCounter(match[1])
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
|
|||||||
export interface PromptInputApi {
|
export interface PromptInputApi {
|
||||||
insertSelection(text: string, mode: PromptInsertMode): void
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
expandTextAttachment(attachmentId: string): void
|
expandTextAttachment(attachmentId: string): void
|
||||||
|
removeAttachment(attachmentId: string): void
|
||||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
focus(): void
|
focus(): void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, type Accessor } from "solid-js"
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findHighestAttachmentCounters,
|
findHighestAttachmentCounters,
|
||||||
formatImagePlaceholder,
|
formatImagePlaceholder,
|
||||||
formatPastedPlaceholder,
|
formatPastedPlaceholder,
|
||||||
|
imageDisplayCounterRegex,
|
||||||
pastedDisplayCounterRegex,
|
pastedDisplayCounterRegex,
|
||||||
} from "./attachmentPlaceholders"
|
} from "./attachmentPlaceholders"
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ type PromptAttachments = {
|
|||||||
attachments: Accessor<Attachment[]>
|
attachments: Accessor<Attachment[]>
|
||||||
pasteCount: Accessor<number>
|
pasteCount: Accessor<number>
|
||||||
imageCount: Accessor<number>
|
imageCount: Accessor<number>
|
||||||
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
syncAttachmentCounters: (promptText: string) => void
|
||||||
|
|
||||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||||
isDragging: Accessor<boolean>
|
isDragging: Accessor<boolean>
|
||||||
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
|
|
||||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
function syncAttachmentCounters(currentPrompt: string) {
|
||||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
|
||||||
setPasteCount(highestPaste)
|
setPasteCount(highestPaste)
|
||||||
setImageCount(highestImage)
|
setImageCount(highestImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
|
||||||
|
const next = currentPrompt.replace(tokenRegex, "")
|
||||||
|
if (next === currentPrompt) return currentPrompt
|
||||||
|
|
||||||
|
return next
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n[ \t]+/g, "\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createLooseImagePlaceholderRegex = (counter: string | number) =>
|
||||||
|
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||||
|
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
|
||||||
|
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||||
|
|
||||||
|
// Keep placeholder-backed attachments in sync with prompt text.
|
||||||
|
// If the placeholder token disappears from the prompt, the attachment should disappear too.
|
||||||
|
createEffect(() => {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const currentAttachments = attachments()
|
||||||
|
|
||||||
|
const toRemove: string[] = []
|
||||||
|
|
||||||
|
for (const attachment of currentAttachments) {
|
||||||
|
if (attachment.source.type === "text") {
|
||||||
|
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (!match) continue
|
||||||
|
const counter = match[1]
|
||||||
|
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
|
||||||
|
toRemove.push(attachment.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||||
|
const match =
|
||||||
|
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||||
|
if (!match) continue
|
||||||
|
const counter = match[1]
|
||||||
|
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
|
||||||
|
toRemove.push(attachment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachmentId of toRemove) {
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleRemoveAttachment(attachmentId: string) {
|
function handleRemoveAttachment(attachmentId: string) {
|
||||||
const currentAttachments = attachments()
|
const currentAttachments = attachments()
|
||||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||||
|
|
||||||
|
// Always remove from store.
|
||||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||||
|
|
||||||
if (attachment) {
|
if (!attachment) return
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
let newPrompt = currentPrompt
|
|
||||||
|
|
||||||
if (attachment.source.type === "file") {
|
const currentPrompt = options.prompt()
|
||||||
if (attachment.mediaType.startsWith("image/")) {
|
let nextPrompt = currentPrompt
|
||||||
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
|
||||||
if (imageMatch) {
|
if (attachment.source.type === "file") {
|
||||||
const placeholder = formatImagePlaceholder(imageMatch[1])
|
if (attachment.mediaType.startsWith("image/")) {
|
||||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
const imageMatch =
|
||||||
}
|
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||||
} else {
|
if (imageMatch) {
|
||||||
const filename = attachment.filename
|
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
|
||||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
|
||||||
}
|
}
|
||||||
} else if (attachment.source.type === "agent") {
|
} else {
|
||||||
const agentName = attachment.filename
|
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
|
||||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
const candidates = [attachment.source.path, attachment.filename]
|
||||||
} else if (attachment.source.type === "text") {
|
for (const candidate of candidates) {
|
||||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
if (!candidate) continue
|
||||||
if (placeholderMatch) {
|
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
|
||||||
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
|
||||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (attachment.source.type === "agent") {
|
||||||
|
const agentName = attachment.filename
|
||||||
|
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
|
||||||
|
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
|
||||||
|
} else if (attachment.source.type === "text") {
|
||||||
|
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (placeholderMatch) {
|
||||||
|
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options.setPrompt(newPrompt)
|
if (nextPrompt !== currentPrompt) {
|
||||||
|
options.setPrompt(nextPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const blob = item.getAsFile()
|
const blob = item.getAsFile()
|
||||||
if (!blob) continue
|
if (!blob) continue
|
||||||
|
|
||||||
const count = imageCount() + 1
|
const { highestImage } = findHighestAttachmentCounters(options.prompt())
|
||||||
|
const count = highestImage + 1
|
||||||
setImageCount(count)
|
setImageCount(count)
|
||||||
|
|
||||||
|
const placeholder = formatImagePlaceholder(count)
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const currentText = options.prompt()
|
||||||
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
options.setPrompt(options.prompt() + placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const base64Data = (reader.result as string).split(",")[1]
|
const base64Data = (reader.result as string).split(",")[1]
|
||||||
const display = formatImagePlaceholder(count)
|
|
||||||
const filename = `image-${count}.png`
|
const filename = `image-${count}.png`
|
||||||
|
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
options.instanceFolder(),
|
options.instanceFolder(),
|
||||||
)
|
)
|
||||||
attachment.url = `data:image/png;base64,${base64Data}`
|
attachment.url = `data:image/png;base64,${base64Data}`
|
||||||
attachment.display = display
|
attachment.display = placeholder
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
const currentText = options.prompt()
|
|
||||||
const placeholder = formatImagePlaceholder(count)
|
|
||||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
|
||||||
options.setPrompt(newText)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const newCursorPos = start + placeholder.length
|
|
||||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
textarea.focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
|
|
||||||
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
if (isLongPaste) {
|
if (isLongPaste) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const count = pasteCount() + 1
|
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
|
||||||
|
const count = highestPaste + 1
|
||||||
setPasteCount(count)
|
setPasteCount(count)
|
||||||
|
|
||||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||||
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const filename = `paste-${count}.txt`
|
const filename = `paste-${count}.txt`
|
||||||
|
|
||||||
const attachment = createTextAttachment(pastedText, display, filename)
|
const attachment = createTextAttachment(pastedText, display, filename)
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
const placeholder = formatPastedPlaceholder(count)
|
||||||
|
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const start = textarea.selectionStart
|
const start = textarea.selectionStart
|
||||||
const end = textarea.selectionEnd
|
const end = textarea.selectionEnd
|
||||||
const currentText = options.prompt()
|
const currentText = options.prompt()
|
||||||
const placeholder = formatPastedPlaceholder(count)
|
|
||||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
options.setPrompt(newText)
|
options.setPrompt(newText)
|
||||||
|
|
||||||
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
|
} else {
|
||||||
|
options.setPrompt(options.prompt() + placeholder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2">
|
<kbd class="kbd ml-2 kbd-hint">
|
||||||
Cmd+Enter
|
Cmd+Enter
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<Show when={attachments().length > 0}>
|
<Show when={attachments().length > 0}>
|
||||||
<PromptAttachmentsBar
|
<PromptAttachmentsBar
|
||||||
attachments={attachments()}
|
attachments={attachments()}
|
||||||
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
onRemoveAttachment={(attachmentId) => {
|
||||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
if (promptInputApi) {
|
||||||
/>
|
promptInputApi.removeAttachment(attachmentId)
|
||||||
</Show>
|
return
|
||||||
|
}
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||||
|
}}
|
||||||
|
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface Command {
|
|||||||
description: Resolvable<string>
|
description: Resolvable<string>
|
||||||
keywords?: Resolvable<string[]>
|
keywords?: Resolvable<string[]>
|
||||||
shortcut?: KeyboardShortcut
|
shortcut?: KeyboardShortcut
|
||||||
|
disabled?: Resolvable<boolean>
|
||||||
action: () => void | Promise<void>
|
action: () => void | Promise<void>
|
||||||
category?: Resolvable<string>
|
category?: Resolvable<string>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
|
|||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
|
|||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
|
toggleKeyboardShortcutHints: () => void
|
||||||
toggleShowTimelineTools: () => void
|
toggleShowTimelineTools: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
action: options.toggleShowTimelineTools,
|
action: options.toggleShowTimelineTools,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "keyboard-shortcut-hints",
|
||||||
|
label: () =>
|
||||||
|
tGlobal(
|
||||||
|
options.preferences().showKeyboardShortcutHints
|
||||||
|
? "commands.keyboardShortcutHints.label.hide"
|
||||||
|
: "commands.keyboardShortcutHints.label.show",
|
||||||
|
),
|
||||||
|
description: () =>
|
||||||
|
tGlobal(
|
||||||
|
runtimeEnv.host === "web"
|
||||||
|
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||||
|
: "commands.keyboardShortcutHints.description",
|
||||||
|
),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||||
|
disabled: () => runtimeEnv.host === "web",
|
||||||
|
action: options.toggleKeyboardShortcutHints,
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "thinking-default-visibility",
|
id: "thinking-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const appMessages = {
|
|||||||
"releases.uiUpdated.title": "UI updated",
|
"releases.uiUpdated.title": "UI updated",
|
||||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Dev build available",
|
||||||
|
"releases.devUpdateAvailable.message": "A new dev build is available: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "View release",
|
||||||
|
|
||||||
"theme.mode.system": "System",
|
"theme.mode.system": "System",
|
||||||
"theme.mode.light": "Light",
|
"theme.mode.light": "Light",
|
||||||
"theme.mode.dark": "Dark",
|
"theme.mode.dark": "Dark",
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
|
||||||
|
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
|
||||||
|
|
||||||
"commands.common.expanded": "Expanded",
|
"commands.common.expanded": "Expanded",
|
||||||
"commands.common.collapsed": "Collapsed",
|
"commands.common.collapsed": "Collapsed",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
|
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
|
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
|
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI actualizada",
|
||||||
|
"releases.uiUpdated.message": "La UI ahora está actualizada a {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Compilación dev disponible",
|
||||||
|
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Ver release",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
|
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
|
||||||
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
|
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
|
||||||
|
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
|
||||||
|
|
||||||
"commands.common.expanded": "Expandido",
|
"commands.common.expanded": "Expandido",
|
||||||
"commands.common.collapsed": "Colapsado",
|
"commands.common.collapsed": "Colapsado",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.",
|
"releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
|
"releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
|
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI mise à jour",
|
||||||
|
"releases.uiUpdated.message": "L'UI est maintenant mise à jour vers {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Build dev disponible",
|
||||||
|
"releases.devUpdateAvailable.message": "Un nouveau build dev est disponible : {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Voir la release",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
|
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
|
||||||
|
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
|
||||||
|
|
||||||
"commands.common.expanded": "Développé",
|
"commands.common.expanded": "Développé",
|
||||||
"commands.common.collapsed": "Réduit",
|
"commands.common.collapsed": "Réduit",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。",
|
"releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。",
|
||||||
"releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。",
|
"releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。",
|
||||||
"releases.upgradeRequired.action.getUpdate": "更新を取得",
|
"releases.upgradeRequired.action.getUpdate": "更新を取得",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI を更新しました",
|
||||||
|
"releases.uiUpdated.message": "UI が {version} に更新されました。",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "開発版が利用可能",
|
||||||
|
"releases.devUpdateAvailable.message": "新しい開発版が利用可能です: {version}。",
|
||||||
|
"releases.devUpdateAvailable.action": "リリースを見る",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
||||||
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
|
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
|
||||||
|
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
|
||||||
|
|
||||||
"commands.common.expanded": "展開",
|
"commands.common.expanded": "展開",
|
||||||
"commands.common.collapsed": "折りたたみ",
|
"commands.common.collapsed": "折りたたみ",
|
||||||
"commands.common.visible": "表示",
|
"commands.common.visible": "表示",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.",
|
"releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.",
|
"releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Получить обновление",
|
"releases.upgradeRequired.action.getUpdate": "Получить обновление",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI обновлён",
|
||||||
|
"releases.uiUpdated.message": "UI теперь обновлён до {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Доступна dev-сборка",
|
||||||
|
"releases.devUpdateAvailable.message": "Доступна новая dev-сборка: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Открыть релиз",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
||||||
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
|
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
|
||||||
|
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
|
||||||
|
|
||||||
"commands.common.expanded": "Развернуто",
|
"commands.common.expanded": "Развернуто",
|
||||||
"commands.common.collapsed": "Свернуто",
|
"commands.common.collapsed": "Свернуто",
|
||||||
"commands.common.visible": "Видимо",
|
"commands.common.visible": "Видимо",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。",
|
"releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。",
|
||||||
"releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。",
|
"releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。",
|
||||||
"releases.upgradeRequired.action.getUpdate": "获取更新",
|
"releases.upgradeRequired.action.getUpdate": "获取更新",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI 已更新",
|
||||||
|
"releases.uiUpdated.message": "UI 已更新到 {version}。",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "可用的开发版",
|
||||||
|
"releases.devUpdateAvailable.message": "有新的开发版可用:{version}。",
|
||||||
|
"releases.devUpdateAvailable.action": "查看发布",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
|
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
|
||||||
|
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
|
||||||
|
|
||||||
"commands.common.expanded": "展开",
|
"commands.common.expanded": "展开",
|
||||||
"commands.common.collapsed": "折叠",
|
"commands.common.collapsed": "折叠",
|
||||||
"commands.common.visible": "可见",
|
"commands.common.visible": "可见",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type ListeningMode = "local" | "all"
|
|||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
|
showKeyboardShortcutHints: boolean
|
||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
showTimelineTools: boolean
|
showTimelineTools: boolean
|
||||||
promptSubmitOnEnter: boolean
|
promptSubmitOnEnter: boolean
|
||||||
@@ -78,6 +79,7 @@ const MAX_FAVORITE_MODELS = 50
|
|||||||
|
|
||||||
const defaultPreferences: Preferences = {
|
const defaultPreferences: Preferences = {
|
||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
|
showKeyboardShortcutHints: true,
|
||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
showTimelineTools: true,
|
showTimelineTools: true,
|
||||||
promptSubmitOnEnter: false,
|
promptSubmitOnEnter: false,
|
||||||
@@ -131,6 +133,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||||
|
showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints,
|
||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
||||||
@@ -393,6 +396,10 @@ function toggleShowThinkingBlocks(): void {
|
|||||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleKeyboardShortcutHints(): void {
|
||||||
|
updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints })
|
||||||
|
}
|
||||||
|
|
||||||
function toggleShowTimelineTools(): void {
|
function toggleShowTimelineTools(): void {
|
||||||
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
||||||
}
|
}
|
||||||
@@ -511,6 +518,7 @@ interface ConfigContextValue {
|
|||||||
setThemePreference: typeof setThemePreference
|
setThemePreference: typeof setThemePreference
|
||||||
updateConfig: typeof updateConfig
|
updateConfig: typeof updateConfig
|
||||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||||
|
toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints
|
||||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
@@ -548,6 +556,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
setThemePreference,
|
setThemePreference,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
@@ -608,6 +617,7 @@ export {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ const log = getLogger("actions")
|
|||||||
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
||||||
|
|
||||||
const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion"
|
const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion"
|
||||||
|
const DEV_RELEASE_STORAGE_KEY = "codenomad:lastSeenDevRelease"
|
||||||
|
const META_REFRESH_INTERVAL_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
let visibilityEffectInitialized = false
|
let visibilityEffectInitialized = false
|
||||||
let activeToast: ToastHandle | null = null
|
let activeToast: ToastHandle | null = null
|
||||||
let activeToastKey: string | null = null
|
let activeToastKey: string | null = null
|
||||||
let uiUpdateToasted = false
|
let uiUpdateToasted = false
|
||||||
|
let metaRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function dismissActiveToast() {
|
function dismissActiveToast() {
|
||||||
if (activeToast) {
|
if (activeToast) {
|
||||||
@@ -80,6 +83,8 @@ async function refreshFromMeta() {
|
|||||||
const meta = await getServerMeta(true)
|
const meta = await getServerMeta(true)
|
||||||
setSupportInfo(meta.support ?? null)
|
setSupportInfo(meta.support ?? null)
|
||||||
maybeNotifyUiUpdated(meta)
|
maybeNotifyUiUpdated(meta)
|
||||||
|
maybeNotifyDevReleaseAvailable(meta)
|
||||||
|
ensureMetaRefresh(meta)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("Unable to load server metadata for support info", error)
|
log.warn("Unable to load server metadata for support info", error)
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,46 @@ function maybeNotifyUiUpdated(meta: ServerMeta) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeNotifyDevReleaseAvailable(meta: ServerMeta) {
|
||||||
|
const update = meta.update
|
||||||
|
if (!update || !update.version || !update.url) return
|
||||||
|
|
||||||
|
const lastSeen = safeReadLocalStorage(DEV_RELEASE_STORAGE_KEY)
|
||||||
|
if (lastSeen === update.version) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
safeWriteLocalStorage(DEV_RELEASE_STORAGE_KEY, update.version)
|
||||||
|
|
||||||
|
showToastNotification({
|
||||||
|
title: tGlobal("releases.devUpdateAvailable.title"),
|
||||||
|
message: tGlobal("releases.devUpdateAvailable.message", { version: update.version }),
|
||||||
|
variant: "info",
|
||||||
|
duration: 12000,
|
||||||
|
position: "bottom-right",
|
||||||
|
action: {
|
||||||
|
label: tGlobal("releases.devUpdateAvailable.action"),
|
||||||
|
href: update.url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMetaRefresh(meta: ServerMeta) {
|
||||||
|
if (metaRefreshInterval) return
|
||||||
|
|
||||||
|
const version = meta.serverVersion?.trim() ?? ""
|
||||||
|
const looksLikeDev = version.includes("-dev.") || version.includes("-dev-")
|
||||||
|
const hasDevUpdateChannel = Boolean(meta.update)
|
||||||
|
|
||||||
|
if (!looksLikeDev && !hasDevUpdateChannel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metaRefreshInterval = setInterval(() => {
|
||||||
|
void refreshFromMeta()
|
||||||
|
}, META_REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
function safeReadLocalStorage(key: string): string | null {
|
function safeReadLocalStorage(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined" || !window.localStorage) return null
|
if (typeof window === "undefined" || !window.localStorage) return null
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "github-markdown-css/github-markdown-dark.css";
|
@import "github-markdown-css/github-markdown-light.css" layer(github-markdown-base);
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
@@ -108,17 +108,23 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body pre {
|
.markdown-body pre:not(.shiki) {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
background-color: var(--surface-code);
|
background-color: var(--surface-code);
|
||||||
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body pre:not(.shiki) code,
|
||||||
|
.markdown-code-block pre:not(.shiki) code {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body blockquote {
|
.markdown-body blockquote {
|
||||||
border-left: 3px solid var(--border-base);
|
border-left: 3px solid var(--border-base);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -151,16 +157,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
display: block;
|
|
||||||
padding-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body thead,
|
|
||||||
.markdown-body tbody,
|
|
||||||
.markdown-body tfoot {
|
|
||||||
width: 100%;
|
|
||||||
display: table;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body th,
|
.markdown-body th,
|
||||||
@@ -168,12 +164,22 @@
|
|||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body th {
|
.markdown-body th {
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body tbody > tr:nth-child(odd) > td {
|
||||||
|
background-color: var(--markdown-table-row-odd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body tbody > tr:nth-child(even) > td {
|
||||||
|
background-color: var(--markdown-table-row-even);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-code-block {
|
.markdown-code-block {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-item:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
--surface-muted: #f8fafc;
|
--surface-muted: #f8fafc;
|
||||||
--surface-code: #f1f5f9;
|
--surface-code: #f1f5f9;
|
||||||
--surface-hover: #e0e0e0;
|
--surface-hover: #e0e0e0;
|
||||||
|
--markdown-table-row-odd: transparent;
|
||||||
|
--markdown-table-row-even: #f1f5f9;
|
||||||
|
|
||||||
/* Border tokens */
|
/* Border tokens */
|
||||||
--border-base: #e0e0e0;
|
--border-base: #e0e0e0;
|
||||||
@@ -180,6 +182,8 @@
|
|||||||
--surface-muted: #212529;
|
--surface-muted: #212529;
|
||||||
--surface-code: #1a1a1a;
|
--surface-code: #1a1a1a;
|
||||||
--surface-hover: #3a3a3a;
|
--surface-hover: #3a3a3a;
|
||||||
|
--markdown-table-row-odd: #0f1114;
|
||||||
|
--markdown-table-row-even: #181c22;
|
||||||
|
|
||||||
/* Border tokens */
|
/* Border tokens */
|
||||||
--border-base: #3a3a3a;
|
--border-base: #3a3a3a;
|
||||||
@@ -347,6 +351,8 @@
|
|||||||
--surface-muted: #212529;
|
--surface-muted: #212529;
|
||||||
--surface-code: #1a1a1a;
|
--surface-code: #1a1a1a;
|
||||||
--surface-hover: #3a3a3a;
|
--surface-hover: #3a3a3a;
|
||||||
|
--markdown-table-row-odd: #0f1114;
|
||||||
|
--markdown-table-row-even: #181c22;
|
||||||
|
|
||||||
/* Border tokens */
|
/* Border tokens */
|
||||||
--border-base: #3a3a3a;
|
--border-base: #3a3a3a;
|
||||||
|
|||||||
@@ -153,6 +153,19 @@
|
|||||||
@apply opacity-50;
|
@apply opacity-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on
|
||||||
|
touch-first devices and in WebUI where browser shortcuts often conflict.
|
||||||
|
*/
|
||||||
|
html[data-runtime-host="web"] .keyboard-hints,
|
||||||
|
html[data-runtime-host="web"] .kbd-hint,
|
||||||
|
html[data-runtime-platform="mobile"] .keyboard-hints,
|
||||||
|
html[data-runtime-platform="mobile"] .kbd-hint,
|
||||||
|
html[data-keyboard-hints="hide"] .keyboard-hints,
|
||||||
|
html[data-keyboard-hints="hide"] .kbd-hint {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Truncate from the start (keeps end visible; good for paths) */
|
/* Truncate from the start (keeps end visible; good for paths) */
|
||||||
.truncate-start {
|
.truncate-start {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user