diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 235e9bc0..4fdc3c0f 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -1,4 +1,4 @@ -name: Dev CI +name: Develop Pre-Release on: push: @@ -7,12 +7,35 @@ on: workflow_dispatch: permissions: - contents: read + id-token: write + contents: write + +concurrency: + group: dev-prerelease + cancel-in-progress: true jobs: - dev-ci: - uses: ./.github/workflows/build-and-upload.yml + prepare: + 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: - upload: false - set_versions: false + version_suffix: ${{ needs.prepare.outputs.version_suffix }} + npm_package_name: "@neuralnomads/codenomad-dev" + dist_tag: latest + prerelease: true + release_ui: false secrets: inherit diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 86b8768a..81d93fd1 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -12,6 +12,11 @@ on: required: false default: dev type: string + package_name: + description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)" + required: false + default: "@neuralnomads/codenomad" + type: string workflow_call: inputs: version: @@ -21,6 +26,13 @@ on: required: false type: string default: dev + package_name: + required: false + type: string + default: "@neuralnomads/codenomad" + secrets: + NPM_TOKEN: + required: false permissions: contents: read @@ -51,7 +63,7 @@ jobs: run: npm install @rollup/rollup-linux-x64-gnu --no-save - 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 shell: bash @@ -62,13 +74,31 @@ jobs: fi echo "VERSION=$VERSION_INPUT" >> "$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 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 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_REGISTRY: https://registry.npmjs.org + shell: bash 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfd07e8e..0ce704b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,4 +14,5 @@ jobs: uses: ./.github/workflows/reusable-release.yml with: dist_tag: latest + npm_package_name: "@neuralnomads/codenomad" secrets: inherit diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 2f6da125..c34959ba 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -13,6 +13,21 @@ on: required: false default: dev 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: id-token: write @@ -53,11 +68,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.versions.outputs.tag }} + IS_PRERELEASE: ${{ inputs.prerelease }} run: | if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists" 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 build-and-upload: @@ -71,6 +91,7 @@ jobs: release-ui: needs: prepare-release + if: ${{ inputs.release_ui }} permissions: contents: read uses: ./.github/workflows/release-ui.yml @@ -84,4 +105,5 @@ jobs: with: version: ${{ needs.prepare-release.outputs.version }} dist_tag: ${{ inputs.dist_tag }} + package_name: ${{ inputs.npm_package_name }} secrets: inherit diff --git a/README.md b/README.md index 71798d16..eae634f3 100644 --- a/README.md +++ b/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 ``` -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 -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 diff --git a/package-lock.json b/package-lock.json index b0157bb3..5c62f29f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.10.3", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.10.3", + "version": "0.11.1", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11879,6 +11879,21 @@ "dev": true, "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": { "version": "17.7.2", "dev": true, @@ -11970,11 +11985,12 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.10.3", + "version": "0.11.1", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", - "@neuralnomads/codenomad": "file:../server" + "@neuralnomads/codenomad": "file:../server", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", @@ -12005,7 +12021,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.10.3", + "version": "0.11.1", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12017,6 +12033,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, @@ -12045,7 +12062,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.10.3", + "version": "0.11.1", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12053,7 +12070,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.10.3", + "version": "0.11.1", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index e93f8d99..412c0278 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.10.3", + "version": "0.11.1", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 5cae1b9d..299fb24c 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.10.3", + "minServerVersion": "0.11.1", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 45cf7f26..a9b940c0 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -5,6 +5,7 @@ import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" import os from "os" import path from "path" +import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -39,6 +40,36 @@ interface CliEntryResolution { 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 { const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH if (target.startsWith("~/")) { @@ -53,11 +84,20 @@ function resolveHostForMode(mode: ListeningMode): string { function readListeningModeFromConfig(): ListeningMode { try { - const configPath = resolveConfigPath(process.env.CLI_CONFIG) - if (!existsSync(configPath)) return "local" - const content = readFileSync(configPath, "utf-8") - const parsed = JSON.parse(content) - const mode = parsed?.preferences?.listeningMode + const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) + + let parsed: any = null + 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?.server?.listeningMode ?? parsed?.preferences?.listeningMode if (mode === "local" || mode === "all") { return mode } diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 37945ffe..092e3692 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.10.3", + "version": "0.11.1", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -36,7 +36,8 @@ }, "dependencies": { "@neuralnomads/codenomad": "file:../server", - "@codenomad/ui": "file:../ui" + "@codenomad/ui": "file:../ui", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index c81d7cf8..efa583b3 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.1.53" + "@opencode-ai/plugin": "1.2.4" } -} \ No newline at end of file +} diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 364fdec1..531f28fe 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -1 +1,4 @@ public/ + +# Local developer config (may contain secrets) +config-*.json diff --git a/packages/server/README.md b/packages/server/README.md index 2eff0a24..cb798eb1 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it: npx @neuralnomads/codenomad --launch ``` +To list all CLI options: + +```sh +npx @neuralnomads/codenomad --help +``` + On startup, CodeNomad prints two URLs: - `Local Connection URL : ...` (used by desktop shells) @@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad 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 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 ` | `CLI_CONFIG` | Config file location | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--log-level ` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | +| `--log-destination ` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) | | `--username ` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | | `--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 | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | +| `--ui-dir ` | `CLI_UI_DIR` | Directory containing the built UI bundle | +| `--ui-dev-server ` | `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 ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) | +| `--ui-manifest-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 diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b46ae731..038a7e79 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.10.3", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.10.3", + "version": "0.11.1", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 5aad36da..dd45c0a8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.10.3", + "version": "0.11.1", "description": "CodeNomad Server", "license": "MIT", "author": { @@ -34,6 +34,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 41e8229b..c3dea831 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -1,7 +1,6 @@ import type { AgentModelSelection, AgentModelSelections, - ConfigFile, ModelPreference, OpenCodeBinary, Preferences, @@ -183,9 +182,9 @@ export interface BinaryRecord { validationError?: string } -export type AppConfig = ConfigFile -export type AppConfigResponse = AppConfig -export type AppConfigUpdateRequest = Partial +export type SettingsOwner = string +export type SettingsBucket = Record +export type SettingsDoc = Record export interface BinaryListResponse { binaries: BinaryRecord[] @@ -214,8 +213,8 @@ export type WorkspaceEventType = | "workspace.error" | "workspace.stopped" | "workspace.log" - | "config.appChanged" - | "config.binariesChanged" + | "storage.configChanged" + | "storage.stateChanged" | "instance.dataChanged" | "instance.event" | "instance.eventStatus" @@ -226,8 +225,8 @@ export type WorkspaceEventPayload = | { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.log"; entry: WorkspaceLogEntry } - | { type: "config.appChanged"; config: AppConfig } - | { type: "config.binariesChanged"; binaries: BinaryRecord[] } + | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } + | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } @@ -286,6 +285,8 @@ export interface ServerMeta { serverVersion?: string ui?: UiMeta support?: SupportMeta + /** Optional update info (dev channel only). */ + update?: LatestReleaseInfo | null } export type BackgroundProcessStatus = "running" | "stopped" | "error" diff --git a/packages/server/src/config/binaries.ts b/packages/server/src/config/binaries.ts deleted file mode 100644 index 56d86d50..00000000 --- a/packages/server/src/config/binaries.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - BinaryCreateRequest, - BinaryRecord, - BinaryUpdateRequest, - BinaryValidationResult, -} from "../api-types" -import { spawnSync } from "child_process" -import { ConfigStore } from "./store" -import { EventBus } from "../events/bus" -import type { ConfigFile } from "./schema" -import { Logger } from "../logger" -import { buildSpawnSpec } from "../workspaces/runtime" - -export class BinaryRegistry { - constructor( - private readonly configStore: ConfigStore, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - list(): BinaryRecord[] { - return this.mapRecords() - } - - resolveDefault(): BinaryRecord { - const binaries = this.mapRecords() - if (binaries.length === 0) { - this.logger.warn("No configured binaries found, falling back to opencode") - return this.buildFallbackRecord("opencode") - } - return binaries.find((binary) => binary.isDefault) ?? binaries[0] - } - - create(request: BinaryCreateRequest): BinaryRecord { - this.logger.debug({ path: request.path }, "Registering OpenCode binary") - const entry = { - path: request.path, - version: undefined, - lastUsed: Date.now(), - label: request.label, - } - - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path) - nextConfig.opencodeBinaries = [entry, ...deduped] - - if (request.makeDefault) { - nextConfig.preferences.lastUsedBinary = request.path - } - - this.configStore.replace(nextConfig) - const record = this.getById(request.path) - this.emitChange() - return record - } - - update(id: string, updates: BinaryUpdateRequest): BinaryRecord { - this.logger.debug({ id }, "Updating OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) => - binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, - ) - - if (updates.makeDefault) { - nextConfig.preferences.lastUsedBinary = id - } - - this.configStore.replace(nextConfig) - const record = this.getById(id) - this.emitChange() - return record - } - - remove(id: string) { - this.logger.debug({ id }, "Removing OpenCode binary") - const config = this.configStore.get() - const nextConfig = this.cloneConfig(config) - const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id) - nextConfig.opencodeBinaries = remaining - - if (nextConfig.preferences.lastUsedBinary === id) { - nextConfig.preferences.lastUsedBinary = remaining[0]?.path - } - - this.configStore.replace(nextConfig) - this.emitChange() - } - - validatePath(path: string): BinaryValidationResult { - this.logger.debug({ path }, "Validating OpenCode binary path") - return this.validateRecord({ - id: path, - path, - label: this.prettyLabel(path), - isDefault: false, - }) - } - - private cloneConfig(config: ConfigFile): ConfigFile { - return JSON.parse(JSON.stringify(config)) as ConfigFile - } - - private mapRecords(): BinaryRecord[] { - - const config = this.configStore.get() - const configuredBinaries = config.opencodeBinaries.map((binary) => ({ - id: binary.path, - path: binary.path, - label: binary.label ?? this.prettyLabel(binary.path), - version: binary.version, - isDefault: false, - })) - - const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode" - - const annotated = configuredBinaries.map((binary) => ({ - ...binary, - isDefault: binary.path === defaultPath, - })) - - if (!annotated.some((binary) => binary.path === defaultPath)) { - annotated.unshift(this.buildFallbackRecord(defaultPath)) - } - - return annotated - } - - private getById(id: string): BinaryRecord { - return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id) - } - - private emitChange() { - this.logger.debug("Emitting binaries changed event") - this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() }) - } - - private validateRecord(record: BinaryRecord): BinaryValidationResult { - const inputPath = record.path - if (!inputPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(inputPath, ["--version"]) - - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } - } - - private buildFallbackRecord(path: string): BinaryRecord { - return { - id: path, - path, - label: this.prettyLabel(path), - isDefault: true, - } - } - - private prettyLabel(path: string) { - const parts = path.split(/[\\/]/) - const last = parts[parts.length - 1] || path - return last || path - } -} diff --git a/packages/server/src/config/location.ts b/packages/server/src/config/location.ts new file mode 100644 index 00000000..b8d150f2 --- /dev/null +++ b/packages/server/src/config/location.ts @@ -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"), + } +} diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 829d491c..c4781113 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({ const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) -const PreferencesSchema = z.object({ +const PreferencesSchema = z + .object({ showThinkingBlocks: z.boolean().default(false), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), showTimelineTools: z.boolean().default(true), @@ -31,7 +32,9 @@ const PreferencesSchema = z.object({ osNotificationsAllowWhenVisible: z.boolean().default(false), notifyOnNeedsInput: z.boolean().default(true), notifyOnIdle: z.boolean().default(true), -}) + }) + // Preserve unknown preference keys so newer configs survive older binaries. + .passthrough() const RecentFolderSchema = z.object({ path: z.string(), @@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({ label: z.string().optional(), }) -const ConfigFileSchema = z.object({ - preferences: PreferencesSchema.default({}), - recentFolders: z.array(RecentFolderSchema).default([]), - opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), - theme: z.enum(["light", "dark", "system"]).optional(), -}) +const ConfigFileSchema = z + .object({ + preferences: PreferencesSchema.default({}), + recentFolders: z.array(RecentFolderSchema).default([]), + 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_YAML = ConfigYamlSchema.parse({}) +const DEFAULT_STATE = StateFileSchema.parse({}) export { ModelPreferenceSchema, @@ -62,7 +86,11 @@ export { RecentFolderSchema, OpenCodeBinarySchema, ConfigFileSchema, + ConfigYamlSchema, + StateFileSchema, DEFAULT_CONFIG, + DEFAULT_CONFIG_YAML, + DEFAULT_STATE, } export type ModelPreference = z.infer @@ -72,3 +100,5 @@ export type Preferences = z.infer export type RecentFolder = z.infer export type OpenCodeBinary = z.infer export type ConfigFile = z.infer +export type ConfigYamlFile = z.infer +export type StateFile = z.infer diff --git a/packages/server/src/config/store.ts b/packages/server/src/config/store.ts deleted file mode 100644 index dda49e40..00000000 --- a/packages/server/src/config/store.ts +++ /dev/null @@ -1,78 +0,0 @@ -import fs from "fs" -import path from "path" -import { EventBus } from "../events/bus" -import { Logger } from "../logger" -import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema" - -export class ConfigStore { - private cache: ConfigFile = DEFAULT_CONFIG - private loaded = false - - constructor( - private readonly configPath: string, - private readonly eventBus: EventBus | undefined, - private readonly logger: Logger, - ) {} - - load(): ConfigFile { - if (this.loaded) { - return this.cache - } - - try { - const resolved = this.resolvePath(this.configPath) - if (fs.existsSync(resolved)) { - const content = fs.readFileSync(resolved, "utf-8") - const parsed = JSON.parse(content) - this.cache = ConfigFileSchema.parse(parsed) - this.logger.debug({ resolved }, "Loaded existing config file") - } else { - this.cache = DEFAULT_CONFIG - this.logger.debug({ resolved }, "No config file found, using defaults") - } - } catch (error) { - this.logger.warn({ err: error }, "Failed to load config, using defaults") - this.cache = DEFAULT_CONFIG - } - - this.loaded = true - return this.cache - } - - get(): ConfigFile { - return this.load() - } - - replace(config: ConfigFile) { - const validated = ConfigFileSchema.parse(config) - this.commit(validated) - } - - private commit(next: ConfigFile) { - this.cache = next - this.loaded = true - this.persist() - const published = Boolean(this.eventBus) - this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) - this.logger.debug({ broadcast: published }, "Config SSE event emitted") - this.logger.trace({ config: this.cache }, "Config payload") - } - - private persist() { - try { - const resolved = this.resolvePath(this.configPath) - fs.mkdirSync(path.dirname(resolved), { recursive: true }) - fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8") - this.logger.debug({ resolved }, "Persisted config file") - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist config") - } - } - - private resolvePath(filePath: string) { - if (filePath.startsWith("~/")) { - return path.join(process.env.HOME ?? "", filePath.slice(2)) - } - return path.resolve(filePath) - } -} diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 61453024..7673f00a 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -24,8 +24,8 @@ export class EventBus extends EventEmitter { this.on("workspace.error", handler) this.on("workspace.stopped", handler) this.on("workspace.log", handler) - this.on("config.appChanged", handler) - this.on("config.binariesChanged", handler) + this.on("storage.configChanged", handler) + this.on("storage.stateChanged", handler) this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) @@ -35,8 +35,8 @@ export class EventBus extends EventEmitter { this.off("workspace.error", handler) this.off("workspace.stopped", handler) this.off("workspace.log", handler) - this.off("config.appChanged", handler) - this.off("config.binariesChanged", handler) + this.off("storage.configChanged", handler) + this.off("storage.stateChanged", handler) this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 38b07d80..74f0f0d6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,8 +8,9 @@ import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" -import { ConfigStore } from "./config/store" -import { BinaryRegistry } from "./config/binaries" +import { resolveConfigLocation } from "./config/location" +import { SettingsService } from "./settings/service" +import { BinaryResolver } from "./settings/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" import { ServerMeta } from "./api-types" @@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses } from "./server/network-addresses" +import { startDevReleaseMonitor } from "./releases/dev-release-monitor" const require = createRequire(import.meta.url) @@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string { 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 { 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 configDir = path.dirname(resolvePath(options.configPath)) + const configLocation = resolveConfigLocation(options.configPath) + const configDir = configLocation.baseDir if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") @@ -266,7 +262,7 @@ async function main() { const authManager = new AuthManager( { - configPath: options.configPath, + configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, @@ -295,19 +291,19 @@ async function main() { const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const configStore = new ConfigStore(options.configPath, eventBus, configLogger) - const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) + const settings = new SettingsService(configLocation, eventBus, configLogger) + const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, - configStore, - binaryRegistry, + settings, + binaryResolver, eventBus, logger: workspaceLogger, getServerBaseUrl: () => serverMeta.localUrl, nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) - const instanceStore = new InstanceStore() + const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -344,6 +340,21 @@ async function main() { 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) { throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") } @@ -372,8 +383,7 @@ async function main() { defaultPort: options.httpPort, protocol: "http", workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, @@ -393,8 +403,7 @@ async function main() { protocol: "https", httpsOptions: tlsResolution?.httpsOptions, workspaceManager, - configStore, - binaryRegistry, + settings, fileSystemBrowser, eventBus, serverMeta, @@ -503,6 +512,8 @@ async function main() { // no-op: remote UI manifest replaces GitHub release monitor + devReleaseMonitor?.stop() + logger.info("Exiting process") process.exit(0) } diff --git a/packages/server/src/releases/dev-release-monitor.ts b/packages/server/src/releases/dev-release-monitor.ts new file mode 100644 index 00000000..5fe405d8 --- /dev/null +++ b/packages/server/src/releases/dev-release-monitor.ts @@ -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 | 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 { + 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, + } +} diff --git a/packages/server/src/releases/release-monitor.ts b/packages/server/src/releases/release-monitor.ts index 2fd80c99..11f97d5c 100644 --- a/packages/server/src/releases/release-monitor.ts +++ b/packages/server/src/releases/release-monitor.ts @@ -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 { const response = await fetch(RELEASES_API_URL, { headers: { @@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise= 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 parsed = Number.parseInt(segment, 10) return Number.isFinite(parsed) ? parsed : 0 diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index caa90c21..3fc7106e 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,12 +9,11 @@ import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" import { EventBus } from "../events/bus" import { registerWorkspaceRoutes } from "./routes/workspaces" -import { registerConfigRoutes } from "./routes/config" +import { registerSettingsRoutes } from "./routes/settings" import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" @@ -37,8 +36,7 @@ interface HttpServerDeps { protocol: "http" | "https" httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } workspaceManager: WorkspaceManager - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService fileSystemBrowser: FileSystemBrowser eventBus: EventBus serverMeta: ServerMeta @@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) - registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) + registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) diff --git a/packages/server/src/server/routes/config.ts b/packages/server/src/server/routes/config.ts deleted file mode 100644 index fed364af..00000000 --- a/packages/server/src/server/routes/config.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { FastifyInstance } from "fastify" -import { z } from "zod" -import { ConfigStore } from "../../config/store" -import { BinaryRegistry } from "../../config/binaries" -import { ConfigFileSchema } from "../../config/schema" - -interface RouteDeps { - configStore: ConfigStore - binaryRegistry: BinaryRegistry -} - -const BinaryCreateSchema = z.object({ - path: z.string(), - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryUpdateSchema = z.object({ - label: z.string().optional(), - makeDefault: z.boolean().optional(), -}) - -const BinaryValidateSchema = z.object({ - path: z.string(), -}) - -export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/api/config/app", async () => deps.configStore.get()) - - app.put("/api/config/app", async (request) => { - const body = ConfigFileSchema.parse(request.body ?? {}) - deps.configStore.replace(body) - return deps.configStore.get() - }) - - app.get("/api/config/binaries", async () => { - return { binaries: deps.binaryRegistry.list() } - }) - - app.post("/api/config/binaries", async (request, reply) => { - const body = BinaryCreateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.create(body) - reply.code(201) - return { binary } - }) - - app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => { - const body = BinaryUpdateSchema.parse(request.body ?? {}) - const binary = deps.binaryRegistry.update(request.params.id, body) - return { binary } - }) - - app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => { - deps.binaryRegistry.remove(request.params.id) - reply.code(204) - }) - - app.post("/api/config/binaries/validate", async (request) => { - const body = BinaryValidateSchema.parse(request.body ?? {}) - return deps.binaryRegistry.validatePath(body.path) - }) -} diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts new file mode 100644 index 00000000..4f5a70eb --- /dev/null +++ b/packages/server/src/server/routes/settings.ts @@ -0,0 +1,110 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { spawnSync } from "child_process" +import { buildSpawnSpec } from "../../workspaces/runtime" +import type { SettingsService } from "../../settings/service" +import type { Logger } from "../../logger" + +interface RouteDeps { + settings: SettingsService + logger: Logger +} + +const ValidateBinarySchema = z.object({ + path: z.string(), +}) + +function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdout = (result.stdout ?? "").trim() + const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) + const normalized = firstLine?.trim() + const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) + const version = versionMatch?.[1] + return { valid: true, version } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { + // Full-document access + app.get("/api/storage/config", async () => deps.settings.getDoc("config")) + app.patch("/api/storage/config", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("config", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => { + return deps.settings.getOwner("config", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get("/api/storage/state", async () => deps.settings.getDoc("state")) + app.patch("/api/storage/state", async (request, reply) => { + try { + return deps.settings.mergePatchDoc("state", request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => { + return deps.settings.getOwner("state", request.params.owner) + }) + + app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => { + try { + return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {}) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid patch" } + } + }) + + // Binary validation helper (used by UI when adding binaries) + app.post("/api/storage/binaries/validate", async (request, reply) => { + try { + const body = ValidateBinarySchema.parse(request.body ?? {}) + return validateBinaryPath(body.path) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to validate binary") + reply.code(400) + return { valid: false, error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts new file mode 100644 index 00000000..e4b25960 --- /dev/null +++ b/packages/server/src/settings/binaries.ts @@ -0,0 +1,55 @@ +import type { SettingsService } from "./service" + +export interface OpenCodeBinaryEntry { + path: string + version?: string + lastUsed?: number + label?: string +} + +export interface ResolvedBinary { + path: string + label: string + version?: string +} + +function prettyLabel(p: string): string { + const parts = p.split(/[\\/]/) + const last = parts[parts.length - 1] || p + return last || p +} + +function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] { + const ui = settings.getOwner("state", "ui") + const list = (ui as any)?.opencodeBinaries + if (!Array.isArray(list)) return [] + return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any +} + +function readDefaultBinaryPath(settings: SettingsService): string | undefined { + const server = settings.getOwner("config", "server") + const value = (server as any)?.opencodeBinary + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + +export class BinaryResolver { + constructor(private readonly settings: SettingsService) {} + + list(): OpenCodeBinaryEntry[] { + return readUiBinaries(this.settings) + } + + resolveDefault(): ResolvedBinary { + const binaries = this.list() + const configuredDefault = readDefaultBinaryPath(this.settings) + const fallback = binaries[0]?.path + const path = configuredDefault ?? fallback ?? "opencode" + + const entry = binaries.find((b) => b.path === path) + return { + path, + label: entry?.label ?? prettyLabel(path), + version: entry?.version, + } + } +} diff --git a/packages/server/src/settings/merge-patch.ts b/packages/server/src/settings/merge-patch.ts new file mode 100644 index 00000000..f5008f46 --- /dev/null +++ b/packages/server/src/settings/merge-patch.ts @@ -0,0 +1,39 @@ +type PlainObject = Record + +export function isPlainObject(value: unknown): value is PlainObject { + if (!value || typeof value !== "object") return false + if (Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** + * RFC 7396-ish merge patch with explicit null deletes. + * - Objects merge recursively + * - Arrays/scalars replace + * - null deletes keys + */ +export function applyMergePatch(current: unknown, patch: unknown): unknown { + if (!isPlainObject(patch)) { + return patch + } + + const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {} + + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key] + continue + } + + const existing = base[key] + if (isPlainObject(value) && isPlainObject(existing)) { + base[key] = applyMergePatch(existing, value) + continue + } + + base[key] = value + } + + return base +} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts new file mode 100644 index 00000000..e693a96d --- /dev/null +++ b/packages/server/src/settings/migrate.ts @@ -0,0 +1,269 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import type { ConfigLocation } from "../config/location" +import { isPlainObject } from "./merge-patch" + +type Doc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function safeReadYaml(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return parseYaml(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read YAML file during migration") + return null + } +} + +function safeReadJson(filePath: string, logger: Logger): unknown { + try { + const content = fs.readFileSync(filePath, "utf-8") + return JSON.parse(content) + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to read JSON file during migration") + return null + } +} + +function writeYaml(filePath: string, doc: Doc, logger: Logger) { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const yaml = stringifyYaml(doc as any) + fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + logger.warn({ err: error, filePath }, "Failed to write YAML file during migration") + } +} + +function pickBackupPath(filePath: string): string { + const preferred = `${filePath}.bak` + if (!fs.existsSync(preferred)) { + return preferred + } + return `${filePath}.bak.${Date.now()}` +} + +function normalizeDoc(value: unknown): Doc { + return isPlainObject(value) ? (value as Doc) : {} +} + +function looksLikeNewOwnerDoc(value: unknown): boolean { + const doc = normalizeDoc(value) + // Heuristic: owner-bucket docs have at least one of these roots. + return Boolean(doc.ui || doc.server || doc.app || doc.legacy) +} + +function looksLikeLegacyConfig(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders) +} + +function looksLikeLegacyState(value: unknown): boolean { + const doc = normalizeDoc(value) + return Boolean(doc.recentFolders) +} + +function omitKeys(source: Doc, keys: Set): Doc { + const out: Doc = {} + for (const [k, v] of Object.entries(source)) { + if (keys.has(k)) continue + out[k] = v + } + return out +} + +function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } { + const cfg = normalizeDoc(legacyConfig) + const st = normalizeDoc(legacyState) + + const outConfig: Doc = {} + const outState: Doc = {} + + const uiConfig: Doc = {} + const uiSettings: Doc = {} + const serverConfig: Doc = {} + const uiState: Doc = {} + + // theme -> config.ui.theme + if (typeof cfg.theme === "string") { + uiConfig.theme = cfg.theme + } + + const preferences = normalizeDoc(cfg.preferences) + if (Object.keys(preferences).length > 0) { + // Server-owned stable keys + const envVars = preferences.environmentVariables + if (isPlainObject(envVars)) { + serverConfig.environmentVariables = envVars + } + const listeningMode = preferences.listeningMode + if (typeof listeningMode === "string") { + serverConfig.listeningMode = listeningMode + } + const lastUsedBinary = preferences.lastUsedBinary + if (typeof lastUsedBinary === "string") { + serverConfig.opencodeBinary = lastUsedBinary + } + + // UI-owned state keys (drop preferences) + const modelRecents = preferences.modelRecents + const modelFavorites = preferences.modelFavorites + const modelThinkingSelections = preferences.modelThinkingSelections + + const models: Doc = {} + if (Array.isArray(modelRecents)) { + models.recents = modelRecents + } + if (Array.isArray(modelFavorites)) { + models.favorites = modelFavorites + } + if (isPlainObject(modelThinkingSelections)) { + models.thinkingSelections = modelThinkingSelections + } + if (Object.keys(models).length > 0) { + uiState.models = models + } + + // Remaining preferences are treated as stable UI settings. + const moved = new Set([ + "environmentVariables", + "listeningMode", + "lastUsedBinary", + "modelRecents", + "modelFavorites", + "modelThinkingSelections", + ]) + Object.assign(uiSettings, omitKeys(preferences, moved)) + } + + // recentFolders lives in legacy state (yaml) or legacy config.json + const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown + if (Array.isArray(recentFolders)) { + uiState.recentFolders = recentFolders + } + + // opencodeBinaries -> state.ui.opencodeBinaries + if (Array.isArray(cfg.opencodeBinaries)) { + uiState.opencodeBinaries = cfg.opencodeBinaries + } + + if (Object.keys(uiSettings).length > 0) { + uiConfig.settings = uiSettings + } + + if (Object.keys(uiConfig).length > 0) { + outConfig.ui = uiConfig + } + if (Object.keys(serverConfig).length > 0) { + outConfig.server = serverConfig + } + if (Object.keys(uiState).length > 0) { + outState.ui = uiState + } + + // Unknown top-level keys -> legacy.unknown + const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"]) + const unknownConfig = omitKeys(cfg, knownConfigKeys) + if (Object.keys(unknownConfig).length > 0) { + outConfig.legacy = { unknown: unknownConfig } + } + + const knownStateKeys = new Set(["recentFolders"]) + const unknownState = omitKeys(st, knownStateKeys) + if (Object.keys(unknownState).length > 0) { + outState.legacy = { unknown: unknownState } + } + + return { config: outConfig, state: outState } +} + +/** + * Migrate older config/state layouts into owner-bucket YAML docs. + * + * Legacy inputs supported: + * - config.yaml with { preferences, opencodeBinaries, theme } + * - state.yaml with { recentFolders } + * - legacy config.json with full ConfigFile schema + */ +export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) { + const configYamlPath = location.configYamlPath + const stateYamlPath = location.stateYamlPath + const legacyJsonPath = location.legacyJsonPath + + const configExists = fs.existsSync(configYamlPath) + const stateExists = fs.existsSync(stateYamlPath) + + const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null + const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null + + const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc) + const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc) + + if (configIsNew && stateIsNew) { + return + } + + const legacyJsonExists = fs.existsSync(legacyJsonPath) + + const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc)) + const shouldMigrateFromJson = !configExists && legacyJsonExists + + if (!hasLegacyYaml && !shouldMigrateFromJson) { + // Either fresh install or partially written docs; let stores create on first write. + return + } + + const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc + const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc + + const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState) + + try { + fs.mkdirSync(location.baseDir, { recursive: true }) + } catch (error) { + logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration") + } + + // Backup legacy files before rewriting. + if (configExists) { + try { + const bak = pickBackupPath(configYamlPath) + fs.renameSync(configYamlPath, bak) + logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml") + } catch (error) { + logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml") + } + } + + if (stateExists) { + try { + const bak = pickBackupPath(stateYamlPath) + fs.renameSync(stateYamlPath, bak) + logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml") + } catch (error) { + logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml") + } + } + + if (shouldMigrateFromJson) { + try { + const bak = pickBackupPath(legacyJsonPath) + fs.renameSync(legacyJsonPath, bak) + logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup") + } catch (error) { + logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup") + } + } + + writeYaml(configYamlPath, config, logger) + writeYaml(stateYamlPath, state, logger) + + logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout") +} diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts new file mode 100644 index 00000000..02a18422 --- /dev/null +++ b/packages/server/src/settings/service.ts @@ -0,0 +1,55 @@ +import type { Logger } from "../logger" +import type { EventBus } from "../events/bus" +import type { ConfigLocation } from "../config/location" +import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" +import { migrateSettingsLayout } from "./migrate" +import type { WorkspaceEventPayload } from "../api-types" + +export type DocKind = "config" | "state" + +export class SettingsService { + private readonly configStore: YamlDocStore + private readonly stateStore: YamlDocStore + + constructor( + private readonly location: ConfigLocation, + private readonly eventBus: EventBus | undefined, + private readonly logger: Logger, + ) { + migrateSettingsLayout(location, logger) + this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" })) + this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" })) + } + + getDoc(kind: DocKind): SettingsDoc { + return kind === "config" ? this.configStore.get() : this.stateStore.get() + } + + mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { + const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) + this.publish(kind, "*") + return updated + } + + getOwner(kind: DocKind, owner: string): SettingsDoc { + return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) + } + + mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { + const updated = + kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) + this.publish(kind, owner, updated) + return updated + } + + private publish(kind: DocKind, owner: string, value?: SettingsDoc) { + if (!this.eventBus) return + const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged" + const payload: WorkspaceEventPayload = { + type, + owner, + value: value ?? this.getOwner(kind, owner), + } as any + this.eventBus.publish(payload) + } +} diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts new file mode 100644 index 00000000..91c5540c --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -0,0 +1,110 @@ +import fs from "fs" +import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" +import type { Logger } from "../logger" +import { applyMergePatch, isPlainObject } from "./merge-patch" + +export type SettingsDoc = Record + +function ensureTrailingNewline(content: string): string { + if (!content) return "\n" + return content.endsWith("\n") ? content : `${content}\n` +} + +function normalizeDoc(input: unknown): SettingsDoc { + if (!isPlainObject(input)) { + return {} + } + return input +} + +export class YamlDocStore { + private cache: SettingsDoc = {} + private loaded = false + + constructor( + private readonly filePath: string, + private readonly logger: Logger, + ) {} + + load(): SettingsDoc { + if (this.loaded) { + return this.cache + } + + try { + if (!fs.existsSync(this.filePath)) { + this.cache = {} + this.loaded = true + return this.cache + } + + const content = fs.readFileSync(this.filePath, "utf-8") + const parsed = parseYaml(content) + this.cache = normalizeDoc(parsed) + this.loaded = true + return this.cache + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object") + this.cache = {} + this.loaded = true + return this.cache + } + } + + get(): SettingsDoc { + return this.load() + } + + replace(next: unknown): SettingsDoc { + const normalized = normalizeDoc(next) + this.cache = normalized + this.loaded = true + this.persist() + return this.cache + } + + mergePatch(patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const current = this.get() + const next = applyMergePatch(current, patch) + return this.replace(next) + } + + getOwner(owner: string): SettingsDoc { + const doc = this.get() + const value = (doc as any)?.[owner] + return normalizeDoc(value) + } + + replaceOwner(owner: string, value: unknown): SettingsDoc { + const doc = this.get() + const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) } + this.replace(nextDoc) + return nextDoc[owner] as SettingsDoc + } + + mergePatchOwner(owner: string, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } + const doc = this.get() + const currentOwner = normalizeDoc((doc as any)?.[owner]) + const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch)) + const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner } + this.replace(nextDoc) + return nextOwner + } + + private persist() { + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + const yaml = stringifyYaml(this.cache as any) + fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") + } catch (error) { + this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + } + } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 8afca60b..a4b50e06 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,8 +2,8 @@ import path from "path" import { spawnSync } from "child_process" import { connect } from "net" import { EventBus } from "../events/bus" -import { ConfigStore } from "../config/store" -import { BinaryRegistry } from "../config/binaries" +import type { SettingsService } from "../settings/service" +import type { BinaryResolver } from "../settings/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" @@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500 interface WorkspaceManagerOptions { rootDir: string - configStore: ConfigStore - binaryRegistry: BinaryRegistry + settings: SettingsService + binaryResolver: BinaryResolver eventBus: EventBus logger: Logger getServerBaseUrl: () => string @@ -86,7 +86,7 @@ export class WorkspaceManager { async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` - const binary = this.options.binaryRegistry.resolveDefault() + const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) @@ -118,8 +118,9 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - const preferences = this.options.configStore.get().preferences ?? {} - const userEnvironment = preferences.environmentVariables ?? {} + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} const opencodeUsername = DEFAULT_OPENCODE_USERNAME const opencodePassword = generateOpencodeServerPassword() diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index ede1e424..e388ea42 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -636,6 +636,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3894,6 +3895,19 @@ dependencies = [ "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]] name = "serialize-to-javascript" version = "0.1.2" @@ -5015,6 +5029,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 86b42d54..adb8357f 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.10.3", + "version": "0.11.1", "private": true, "license": "MIT", "scripts": { diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 99496fe7..f119c846 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] } tauri = { version = "2.5.2", features = [ "devtools"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" regex = "1" once_cell = "1" parking_lot = "0.12" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 9e3242ea..6b55b945 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -141,16 +141,44 @@ struct PreferencesConfig { } #[derive(Debug, Deserialize)] -struct AppConfig { - preferences: Option, +struct ServerConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, } -fn resolve_config_path() -> PathBuf { +#[derive(Debug, Deserialize)] +struct AppConfig { + preferences: Option, + server: Option, +} + +fn resolve_config_locations() -> (PathBuf, PathBuf) { let raw = env::var("CLI_CONFIG") .ok() .filter(|value| !value.trim().is_empty()) .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 { @@ -163,14 +191,46 @@ fn expand_home(path: &str) -> PathBuf { } fn resolve_listening_mode() -> String { - let path = resolve_config_path(); - if let Ok(content) = fs::read_to_string(path) { - if let Ok(config) = serde_json::from_str::(&content) { - if let Some(mode) = config - .preferences + let (yaml_path, json_path) = resolve_config_locations(); + + if let Ok(content) = fs::read_to_string(&yaml_path) { + if let Ok(config) = serde_yaml::from_str::(&content) { + let mode = config + .server .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - { + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + + if let Some(mode) = mode { + 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::(&content) { + let mode = config + .server + .as_ref() + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } @@ -260,7 +320,14 @@ impl CliProcessManager { let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { - if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, 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}")); let mut locked = status_arc.lock(); locked.state = CliState::Error; @@ -369,7 +436,9 @@ impl CliProcessManager { if !supports_user_shell() { 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 +489,6 @@ impl CliProcessManager { let token_clone = bootstrap_token.clone(); thread::spawn(move || { - let stdout = child_clone .lock() .as_mut() @@ -433,10 +501,24 @@ impl CliProcessManager { .map(BufReader::new); 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 { - 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 +591,14 @@ impl CliProcessManager { if locked.error.is_none() { locked.error = err_msg.clone(); } - log_line(&format!("cli process exited before ready: {:?}", locked.error)); - let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()})); + log_line(&format!( + "cli process exited before ready: {:?}", + locked.error + )); + let _ = app_clone.emit( + "cli:error", + json!({"message": locked.error.clone().unwrap_or_default()}), + ); } else { locked.state = CliState::Stopped; log_line("cli process stopped cleanly"); @@ -574,13 +662,25 @@ impl CliProcessManager { .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|m| m.as_str().parse::().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; } if let Ok(value) = serde_json::from_str::(line) { 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; } } @@ -719,7 +819,12 @@ impl CliEntry { } fn build_args(&self, dev: bool, host: &str) -> Vec { - 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 { // Dev: plain HTTP + Vite dev server proxy. @@ -761,9 +866,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option { std::env::current_dir() .ok() .map(|p| p.join("node_modules/tsx/dist/cli.js")), - std::env::current_exe() - .ok() - .and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))), + std::env::current_exe().ok().and_then(|ex| { + ex.parent() + .map(|p| p.join("../node_modules/tsx/dist/cli.js")) + }), ]; first_existing(candidates) @@ -786,7 +892,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { let base = workspace_root(); let mut candidates: Vec> = vec![ 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/index.js")), ]; @@ -801,7 +908,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { 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/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")]; for root in linux_resource_roots { @@ -820,8 +929,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { first_existing(candidates) } -fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result { - +fn build_shell_command_string( + entry: &CliEntry, + cli_args: &[String], +) -> anyhow::Result { let shell = default_shell(); let mut quoted: Vec = Vec::new(); quoted.push(shell_escape(&entry.node_binary)); @@ -852,7 +963,7 @@ fn shell_escape(input: &str) -> String { "''".to_string() } else if !input .chars() - .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' )) + .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) { input.to_string() } else { diff --git a/packages/ui/package.json b/packages/ui/package.json index 83cbeb57..7a75280f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.10.3", + "version": "0.11.1", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f4092ad3..7475c486 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -58,8 +58,10 @@ const App: Component = () => { const { t } = useI18n() const { preferences, + serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, @@ -80,6 +82,13 @@ const App: Component = () => { const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) 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 = () => { if (typeof document === "undefined") return const element = document.querySelector(".tab-bar-instance") @@ -177,7 +186,7 @@ const App: Component = () => { return } setIsSelectingFolder(true) - const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" + const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" try { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() @@ -293,6 +302,7 @@ const App: Component = () => { preferences, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleUsageMetrics, togglePromptSubmitOnEnter, @@ -451,25 +461,17 @@ const App: Component = () => {
- setIsAdvancedSettingsOpen(true)} onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} + onClose={() => { + setShowFolderSelection(false) + setIsAdvancedSettingsOpen(false) + clearLaunchError() + }} />
diff --git a/packages/ui/src/components/command-palette.tsx b/packages/ui/src/components/command-palette.tsx index 36416617..87947b5a 100644 --- a/packages/ui/src/components/command-palette.tsx +++ b/packages/ui/src/components/command-palette.tsx @@ -112,6 +112,10 @@ const CommandPalette: Component = (props) => { const groupedCommandList = () => processedCommands().groups const orderedCommands = () => processedCommands().ordered + + const isCommandDisabled = (command: Command) => { + return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false + } const selectedIndex = createMemo(() => { const ordered = orderedCommands() if (ordered.length === 0) return -1 @@ -138,10 +142,11 @@ const CommandPalette: Component = (props) => { } return } - + const currentId = selectedCommandId() 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 = (props) => { if (index < 0 || index >= ordered.length) return const command = ordered[index] if (!command) return + if (isCommandDisabled(command)) return props.onExecute(command) props.onClose() } } function handleCommandClick(command: Command) { + if (isCommandDisabled(command)) return props.onExecute(command) props.onClose() } @@ -265,11 +272,13 @@ const CommandPalette: Component = (props) => { {(command, localIndex) => { const commandIndex = group.startIndex + localIndex() + const disabled = isCommandDisabled(command) return ( +
+ + @@ -548,7 +560,7 @@ const FolderSelectionView: Component = (props) => { : t("folderSelection.browse.button")} - + @@ -573,7 +585,7 @@ const FolderSelectionView: Component = (props) => { - @@ -539,7 +539,7 @@ const InstanceWelcomeView: Component = (props) => { - -
+
pasteCount: Accessor imageCount: Accessor - syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void + syncAttachmentCounters: (promptText: string) => void handlePaste: (e: ClipboardEvent) => Promise isDragging: Accessor @@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) - function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { - const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) + function syncAttachmentCounters(currentPrompt: string) { + const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt) setPasteCount(highestPaste) 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) { const currentAttachments = attachments() const attachment = currentAttachments.find((a) => a.id === attachmentId) + // Always remove from store. removeAttachment(options.instanceId(), options.sessionId(), attachmentId) - if (attachment) { - const currentPrompt = options.prompt() - let newPrompt = currentPrompt + if (!attachment) return - if (attachment.source.type === "file") { - if (attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex) - if (imageMatch) { - const placeholder = formatImagePlaceholder(imageMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() - } - } else { - const filename = attachment.filename - newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() + const currentPrompt = options.prompt() + let nextPrompt = currentPrompt + + if (attachment.source.type === "file") { + if (attachment.mediaType.startsWith("image/")) { + const imageMatch = + attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) + if (imageMatch) { + nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1])) } - } else if (attachment.source.type === "agent") { - const agentName = attachment.filename - newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() - } else if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) - if (placeholderMatch) { - const placeholder = formatPastedPlaceholder(placeholderMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() + } else { + // For file mentions we insert `@`, but the chip might display `@`. + const candidates = [attachment.source.path, attachment.filename] + for (const candidate of candidates) { + if (!candidate) continue + const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i") + nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex) } } + } 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() if (!blob) continue - const count = imageCount() + 1 + const { highestImage } = findHighestAttachmentCounters(options.prompt()) + const count = highestImage + 1 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() reader.onload = () => { const base64Data = (reader.result as string).split(",")[1] - const display = formatImagePlaceholder(count) const filename = `image-${count}.png` const attachment = createFileAttachment( @@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA options.instanceFolder(), ) attachment.url = `data:image/png;base64,${base64Data}` - attachment.display = display + attachment.display = placeholder 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) @@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA if (isLongPaste) { e.preventDefault() - const count = pasteCount() + 1 + const { highestPaste } = findHighestAttachmentCounters(options.prompt()) + const count = highestPaste + 1 setPasteCount(count) const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` @@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const filename = `paste-${count}.txt` const attachment = createTextAttachment(pastedText, display, filename) - addAttachment(options.instanceId(), options.sessionId(), attachment) - + const placeholder = formatPastedPlaceholder(count) const textarea = options.getTextarea() if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const currentText = options.prompt() - const placeholder = formatPastedPlaceholder(count) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) options.setPrompt(newText) @@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() }, 0) + } else { + options.setPrompt(options.prompt() + placeholder) } + + addAttachment(options.instanceId(), options.sessionId(), attachment) } } diff --git a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts index 18d1746e..ab979216 100644 --- a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts +++ b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts @@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) { const currentAttachments = options.getAttachments() - const attachment = currentAttachments.find( - (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, - ) + const attachment = currentAttachments.find((a) => { + if (a.source.type === "agent") { + return a.filename === name + } + if (a.source.type === "file") { + // Match either by filename (basename) or by path (for full paths like @docs/file.txt) + return ( + a.filename === name || + a.source.path === name || + a.source.path.endsWith("/" + name) || + a.source.path === name.replace(/\/$/, "") + ) + } + if (a.source.type === "text") { + // For text attachments (path-only mentions), match by value + return a.source.value === name || a.source.value.endsWith("/" + name) + } + return false + }) if (attachment) { e.preventDefault() @@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) { textarea.setSelectionRange(mentionStart, mentionStart) }, 0) + // Check if there are any @ remaining in the text - if not, close the picker + if (!newText.includes("@") && options.isPickerOpen()) { + options.closePicker() + // Clear ignoredAtPositions since we deleted the entire @mention + // This ensures typing @ again will open the picker + options.setIgnoredAtPositions(new Set()) + } + return } } diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index ada32cc9..b0056bc9 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -1,9 +1,10 @@ import { createSignal, type Accessor, type Setter } from "solid-js" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Agent } from "../../types/session" -import { createAgentAttachment, createFileAttachment } from "../../types/attachment" +import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { addAttachment, getAttachments } from "../../stores/attachments" import type { PickerMode } from "./types" +import type { PickerSelectAction } from "../unified-picker" type PickerItem = | { type: "agent"; agent: Agent } @@ -37,7 +38,7 @@ type PromptPickerController = { setIgnoredAtPositions: Setter> handleInput: (e: Event) => void - handlePickerSelect: (item: PickerItem) => void + handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void handlePickerClose: () => void } @@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr setAtPosition(null) } - function handlePickerSelect(item: PickerItem) { + function handlePickerSelect(item: PickerItem, action: PickerSelectAction) { const textarea = options.getTextarea() if (item.type === "command") { + // For commands, Tab/Enter/Shift+Enter/click all mean "select". const name = item.command.name const currentPrompt = options.prompt() @@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } }, 0) } else if (item.type === "agent") { + // For agents, Tab/Enter/Shift+Enter/click all mean "select". const agentName = item.agent.name const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const alreadyAttached = existingAttachments.some( @@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const relativePath = item.file.relativePath ?? displayPath const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") - if (isFolder) { - const currentPrompt = options.prompt() - const pos = atPosition() - const cursorPos = textarea?.selectionStart || 0 - const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" - - if (pos !== null) { - const before = currentPrompt.substring(0, pos + 1) - const after = currentPrompt.substring(cursorPos) - const newPrompt = before + folderMention + after - options.setPrompt(newPrompt) - setSearchQuery(folderMention) - - setTimeout(() => { - const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + 1 + folderMention.length - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - - return - } - - const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath - const pathSegments = normalizedPath.split("/") - const filename = (() => { - const candidate = pathSegments[pathSegments.length - 1] || normalizedPath - return candidate === "." ? "/" : candidate - })() - - const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, - ) - - if (!alreadyAttached) { - const attachment = createFileAttachment( - normalizedPath, - filename, - "text/plain", - undefined, - options.instanceFolder(), - ) - addAttachment(options.instanceId(), options.sessionId(), attachment) - } - - const currentPrompt = options.prompt() const pos = atPosition() const cursorPos = textarea?.selectionStart || 0 - if (pos !== null) { + const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => { + if (pos === null) return + const currentPrompt = options.prompt() const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${normalizedPath}` - const newPrompt = before + attachmentText + " " + after - options.setPrompt(newPrompt) + const suffix = opts?.trailingSpace ? " " : "" + const nextPrompt = before + mentionText + suffix + after + options.setPrompt(nextPrompt) setTimeout(() => { const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + attachmentText.length + 1 - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } + if (!nextTextarea) return + const nextCursorPos = pos + mentionText.length + suffix.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) }, 0) } + + const replaceMentionQueryAfterAt = (value: string) => { + // Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation. + if (pos === null) return + const currentPrompt = options.prompt() + const before = currentPrompt.substring(0, pos + 1) + const after = currentPrompt.substring(cursorPos) + const nextPrompt = before + value + after + options.setPrompt(nextPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (!nextTextarea) return + const nextCursorPos = pos + 1 + value.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) + }, 0) + } + + const folderMention = + relativePath === "." || relativePath === "" || relativePath === "./" + ? "./" + : (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/") + + const normalizedFolderPath = (() => { + const trimmed = relativePath.replace(/\/+$/, "") + // If it's root "./", just return "./" + if (trimmed === "" || trimmed === ".") return "./" + // Otherwise remove any leading ./ and add ./ prefix + return "./" + trimmed.replace(/^\.\//, "") + })() + + const addPathOnlyAttachment = (value: string) => { + const display = `path: ${value}` + const filename = value + const existing = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existing.some( + (att) => att.source.type === "text" && att.source.value === value && att.display === display, + ) + if (!alreadyAttached) { + addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename)) + } + } + + if (isFolder) { + if (action === "tab") { + // TAB on directory: autocomplete directory name and show its contents. + replaceMentionQueryAfterAt(folderMention) + setSearchQuery(folderMention) + return + } + + const mentionText = `@${folderMention}` + + if (action === "shiftEnter") { + // SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending + // Always prefix with ./ for consistency + const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath + addPathOnlyAttachment(normalizedFolderPathWithPrefix) + replaceMentionToken(mentionText, { trailingSpace: true }) + } else { + // ENTER/click on directory: attach as a file part pointing at a file:// directory URL. + const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath + const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory", + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedFolderPath, + dirFilename, + "inode/directory", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(mentionText, { trailingSpace: true }) + } + } else { + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + + if (action === "tab") { + // TAB on file: autocomplete the file path but do not attach. + replaceMentionToken(`@${normalizedPath}`) + setSearchQuery(normalizedPath) + return + } + + if (action === "shiftEnter") { + // SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending + // Always prefix with ./ for consistency + const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath + addPathOnlyAttachment(normalizedPathWithPrefix) + replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true }) + } else { + // ENTER/click on file: attach file (existing behavior). + // Always prefix with ./ for consistency + const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix, + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedPathWithPrefix, + filename, + "text/plain", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true }) + } + } } setShowPicker(false) @@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const pos = atPosition() if (pickerMode() === "mention" && pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) + + // Remove the partial @mention text from the textarea when ESC is pressed + const textarea = options.getTextarea() + if (textarea) { + const currentPrompt = options.prompt() + const cursorPos = textarea.selectionStart + // Remove text from @ position to cursor position + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + options.setPrompt(before + after) + + // Restore cursor position to where @ was + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + nextTextarea.setSelectionRange(pos, pos) + } + }, 0) + + // Clear ignoredAtPositions so typing @ again will work + setIgnoredAtPositions(new Set()) + } } setShowPicker(false) setAtPosition(null) diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index e6589b5c..08815a15 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" import { restartCli } from "../lib/native/cli" -import { preferences, setListeningMode } from "../stores/preferences" +import { serverSettings, setListeningMode } from "../stores/preferences" import { showConfirmDialog } from "../stores/alerts" import { getLogger } from "../lib/logger" import { useI18n } from "../lib/i18n" @@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [savingPassword, setSavingPassword] = createSignal(false) const addresses = createMemo(() => meta()?.addresses ?? []) - const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const allowExternalConnections = createMemo(() => currentMode() === "all") const displayAddresses = createMemo(() => { const list = addresses() diff --git a/packages/ui/src/components/session-picker.tsx b/packages/ui/src/components/session-picker.tsx index b564bab2..b67aee8e 100644 --- a/packages/ui/src/components/session-picker.tsx +++ b/packages/ui/src/components/session-picker.tsx @@ -172,7 +172,7 @@ const SessionPicker: Component = (props) => {
- + Cmd+Enter diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index cce1b454..e0de2457 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -299,13 +299,19 @@ export const SessionView: Component = (props) => { /> - 0}> - removeAttachment(props.instanceId, props.sessionId, attachmentId)} - onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} - /> - + 0}> + { + if (promptInputApi) { + promptInputApi.removeAttachment(attachmentId) + return + } + removeAttachment(props.instanceId, props.sessionId, attachmentId) + }} + onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} + /> + void + onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void agents: Agent[] commands?: SDKCommand[] @@ -266,6 +266,13 @@ const UnifiedPicker: Component = (props) => { const workspaceChanged = lastWorkspaceId !== props.workspaceId const queryChanged = lastQuery !== props.searchQuery + if (queryChanged) { + // Reset selectedIndex to 0 when query changes to avoid ghost state + // This ensures proper highlighting when navigating back to root or changing queries + setSelectedIndex(0) + resetScrollPosition() + } + if (!isInitialized() || workspaceChanged || queryChanged) { setIsInitialized(true) lastWorkspaceId = props.workspaceId @@ -341,7 +348,22 @@ const UnifiedPicker: Component = (props) => { return items } - filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) + // Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/") + const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./" + if (mode() === "mention" && isExactRootQuery) { + const rootFile: FileItem = { + path: ".", + relativePath: ".", + isDirectory: true, + isGitFile: false, + } + items.push({ type: "file", file: rootFile }) + } + + // Don't show agents for exact root path queries + if (!isExactRootQuery) { + filteredAgents().forEach((agent) => items.push({ type: "agent", agent })) + } files().forEach((file) => items.push({ type: "file", file })) return items } @@ -356,7 +378,7 @@ const UnifiedPicker: Component = (props) => { } function handleSelect(item: PickerItem) { - props.onSelect(item) + props.onSelect(item, "click") } function handleKeyDown(e: KeyboardEvent) { @@ -379,7 +401,8 @@ const UnifiedPicker: Component = (props) => { e.stopPropagation() const selected = items[selectedIndex()] if (selected) { - handleSelect(selected) + const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" + props.onSelect(selected, action) } } else if (e.key === "Escape") { e.preventDefault() @@ -443,7 +466,7 @@ const UnifiedPicker: Component = (props) => {
handleSelect({ type: "command", command })} + onClick={() => props.onSelect({ type: "command", command }, "click")} >
@@ -464,7 +487,7 @@ const UnifiedPicker: Component = (props) => { - 0}> + 0 && !(props.searchQuery === "." || props.searchQuery === "./")}> @@ -479,7 +502,7 @@ const UnifiedPicker: Component = (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "agent", agent })} + onClick={() => props.onSelect({ type: "agent", agent }, "click")} >
= (props) => { - 0}> + 0 || props.searchQuery === "." || props.searchQuery === "./")}> + +
{ + const rootFile: FileItem = { + path: ".", + relativePath: ".", + isDirectory: true, + isGitFile: false, + } + props.onSelect({ type: "file", file: rootFile }, "click") + }} + > +
+ + + + . {t("unifiedPicker.sections.workspaceRoot")} +
+
+
{(file) => { const itemIndex = allItems().findIndex( @@ -535,7 +587,7 @@ const UnifiedPicker: Component = (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "file", file })} + onClick={() => props.onSelect({ type: "file", file }, "click")} >
{ - return request("/api/config/app") + fetchConfigOwner = Record>(owner: string): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`) }, - updateConfig(payload: AppConfig): Promise { - return request("/api/config/app", { - method: "PUT", - body: JSON.stringify(payload), - }) - }, - listBinaries(): Promise { - return request("/api/config/binaries") - }, - createBinary(payload: BinaryCreateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", { - method: "POST", - body: JSON.stringify(payload), - }) - }, - - updateBinary(id: string, updates: BinaryUpdateRequest) { - return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, { + patchConfigOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/config/${encodeURIComponent(owner)}`, { method: "PATCH", - body: JSON.stringify(updates), + body: JSON.stringify(patch ?? {}), + }) + }, + fetchStateOwner = Record>(owner: string): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`) + }, + patchStateOwner = Record>(owner: string, patch: unknown): Promise { + return request(`/api/storage/state/${encodeURIComponent(owner)}`, { + method: "PATCH", + body: JSON.stringify(patch ?? {}), }) }, - deleteBinary(id: string): Promise { - return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" }) - }, validateBinary(path: string): Promise { - return request("/api/config/binaries/validate", { + return request("/api/storage/binaries/validate", { method: "POST", body: JSON.stringify({ path }), }) diff --git a/packages/ui/src/lib/commands.ts b/packages/ui/src/lib/commands.ts index a38b2fec..f158da5f 100644 --- a/packages/ui/src/lib/commands.ts +++ b/packages/ui/src/lib/commands.ts @@ -18,6 +18,7 @@ export interface Command { description: Resolvable keywords?: Resolvable shortcut?: KeyboardShortcut + disabled?: Resolvable action: () => void | Promise category?: Resolvable } diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index bdd2e8d5..c89a95d4 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -14,6 +14,7 @@ import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" +import { runtimeEnv } from "../runtime-env" const log = getLogger("actions") @@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor toggleShowThinkingBlocks: () => void + toggleKeyboardShortcutHints: () => void toggleShowTimelineTools: () => void toggleUsageMetrics: () => void toggleAutoCleanupBlankSessions: () => void @@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) { 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({ id: "thinking-default-visibility", label: () => { diff --git a/packages/ui/src/lib/i18n/messages/en/app.ts b/packages/ui/src/lib/i18n/messages/en/app.ts index ec1600e6..c1d20689 100644 --- a/packages/ui/src/lib/i18n/messages/en/app.ts +++ b/packages/ui/src/lib/i18n/messages/en/app.ts @@ -30,6 +30,10 @@ export const appMessages = { "releases.uiUpdated.title": "UI updated", "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.light": "Light", "theme.mode.dark": "Dark", diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index 66ff78f7..dd0d12f7 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline", "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.collapsed": "Collapsed", "commands.common.visible": "Visible", @@ -158,6 +164,7 @@ export const commandMessages = { "unifiedPicker.sections.commands": "COMMANDS", "unifiedPicker.sections.agents": "AGENTS", "unifiedPicker.sections.files": "FILES", + "unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT", "unifiedPicker.badge.subagent": "subagent", "unifiedPicker.footer.navigate": "navigate", "unifiedPicker.footer.select": "select", diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index 0cba5509..699c50d6 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "Expand session", "sessionList.expand.collapseTitle": "Collapse", "sessionList.expand.expandTitle": "Expand", + "sessionList.actions.newSession.ariaLabel": "New session", + "sessionList.actions.newSession.title": "New session", "sessionList.actions.copyId.ariaLabel": "Copy session ID", "sessionList.actions.copyId.title": "Copy session ID", "sessionList.actions.rename.ariaLabel": "Rename session", diff --git a/packages/ui/src/lib/i18n/messages/es/app.ts b/packages/ui/src/lib/i18n/messages/es/app.ts index 9d5ac8ce..9fd24e92 100644 --- a/packages/ui/src/lib/i18n/messages/es/app.ts +++ b/packages/ui/src/lib/i18n/messages/es/app.ts @@ -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.noVersion": "Actualiza CodeNomad para usar la UI más reciente.", "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 diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 0bad4e2f..c6a75e7e 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -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.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.collapsed": "Colapsado", "commands.common.visible": "Visible", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index ecdac0d7..8c9dc7b4 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "Expandir sesión", "sessionList.expand.collapseTitle": "Colapsar", "sessionList.expand.expandTitle": "Expandir", + "sessionList.actions.newSession.ariaLabel": "Nueva sesión", + "sessionList.actions.newSession.title": "Nueva sesión", "sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión", "sessionList.actions.copyId.title": "Copiar ID de sesión", "sessionList.actions.rename.ariaLabel": "Renombrar sesión", diff --git a/packages/ui/src/lib/i18n/messages/fr/app.ts b/packages/ui/src/lib/i18n/messages/fr/app.ts index b014c89a..0dffcf65 100644 --- a/packages/ui/src/lib/i18n/messages/fr/app.ts +++ b/packages/ui/src/lib/i18n/messages/fr/app.ts @@ -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.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.", "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 diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 52bdea76..63e7c666 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -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.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.collapsed": "Réduit", "commands.common.visible": "Visible", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 7bee6007..64baf1c8 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "Développer la session", "sessionList.expand.collapseTitle": "Réduire", "sessionList.expand.expandTitle": "Développer", + "sessionList.actions.newSession.ariaLabel": "Nouvelle session", + "sessionList.actions.newSession.title": "Nouvelle session", "sessionList.actions.copyId.ariaLabel": "Copier l'ID de session", "sessionList.actions.copyId.title": "Copier l'ID de session", "sessionList.actions.rename.ariaLabel": "Renommer la session", diff --git a/packages/ui/src/lib/i18n/messages/ja/app.ts b/packages/ui/src/lib/i18n/messages/ja/app.ts index b592cc59..d1aea577 100644 --- a/packages/ui/src/lib/i18n/messages/ja/app.ts +++ b/packages/ui/src/lib/i18n/messages/ja/app.ts @@ -26,4 +26,11 @@ export const appMessages = { "releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。", "releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。", "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 diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 30a2adc5..75c1c5f3 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え", "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.collapsed": "折りたたみ", "commands.common.visible": "表示", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index ca5e2d09..ae7e60a2 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "セッションを展開", "sessionList.expand.collapseTitle": "折りたたむ", "sessionList.expand.expandTitle": "展開", + "sessionList.actions.newSession.ariaLabel": "新しいセッション", + "sessionList.actions.newSession.title": "新しいセッション", "sessionList.actions.copyId.ariaLabel": "セッション ID をコピー", "sessionList.actions.copyId.title": "セッション ID をコピー", "sessionList.actions.rename.ariaLabel": "セッション名を変更", diff --git a/packages/ui/src/lib/i18n/messages/ru/app.ts b/packages/ui/src/lib/i18n/messages/ru/app.ts index 410318bd..dd2c50fb 100644 --- a/packages/ui/src/lib/i18n/messages/ru/app.ts +++ b/packages/ui/src/lib/i18n/messages/ru/app.ts @@ -26,4 +26,11 @@ export const appMessages = { "releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.", "releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.", "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 diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 6c3f28ec..068f020d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений", "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.collapsed": "Свернуто", "commands.common.visible": "Видимо", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index f15194e2..8d348183 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "Развернуть сессию", "sessionList.expand.collapseTitle": "Свернуть", "sessionList.expand.expandTitle": "Развернуть", + "sessionList.actions.newSession.ariaLabel": "Новая сессия", + "sessionList.actions.newSession.title": "Новая сессия", "sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии", "sessionList.actions.copyId.title": "Скопировать ID сессии", "sessionList.actions.rename.ariaLabel": "Переименовать сессию", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts index f3dcbc5a..477a447b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts @@ -26,4 +26,11 @@ export const appMessages = { "releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。", "releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。", "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 diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index 9c95f63e..85997488 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -97,6 +97,12 @@ export const commandMessages = { "commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目", "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.collapsed": "折叠", "commands.common.visible": "可见", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index 3dfc79a8..c9ebb0d3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -21,6 +21,8 @@ export const sessionMessages = { "sessionList.expand.expandAriaLabel": "展开会话", "sessionList.expand.collapseTitle": "折叠", "sessionList.expand.expandTitle": "展开", + "sessionList.actions.newSession.ariaLabel": "新建会话", + "sessionList.actions.newSession.title": "新建会话", "sessionList.actions.copyId.ariaLabel": "复制会话 ID", "sessionList.actions.copyId.title": "复制会话 ID", "sessionList.actions.rename.ariaLabel": "重命名会话", diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 1d8ee1eb..b5c8ee55 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,12 +1,59 @@ -import type { Attachment } from "../types/attachment" +import type { Attachment, FileSource } from "../types/attachment" export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { - if (!prompt || !prompt.includes("[pasted #")) { + if (!prompt) { return prompt } + const fileAttachments = new Set( + attachments + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.path), + ) + + const pathAttachments = new Set( + attachments + .filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:")) + .map((a) => (a.source as { value: string }).value), + ) + + let result = prompt + + // Step 1: Handle root paths FIRST using unique placeholders + // Replace longer pattern first to avoid partial match issues + result = result.replace(/@(\.\/)/g, "___ROOT___") + result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") + // Note: The regex @(\.)(?!\.) means @. NOT followed by another . + + // Step 2: Build set of non-root paths + const allPaths = new Set() + for (const p of fileAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + for (const p of pathAttachments) { + if (p && p !== "." && p !== "./") allPaths.add(p) + } + + // Step 3: Replace @path with ./path for non-root paths + for (const path of allPaths) { + if (!path) continue + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : "./" + path + result = result.replace("@" + withoutPrefix, withPrefix) + result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") + } + + // Step 4: Convert placeholders back to ./ + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") + + // Step 5: Resolve [pasted #N] placeholders + if (!result.includes("[pasted #")) { + return result + } + if (!attachments || attachments.length === 0) { - return prompt + return result } const lookup = new Map() @@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen const source = attachment?.source if (!source || source.type !== "text") continue const display = attachment?.display - const value = source.value + const value = (source as { value?: string }).value if (typeof display !== "string" || typeof value !== "string") continue const match = display.match(/pasted #(\d+)/) if (!match) continue @@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen } if (lookup.size === 0) { - return prompt + return result } - return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { + return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { const replacement = lookup.get(fullMatch) return typeof replacement === "string" ? replacement : fullMatch }) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index d777ef38..77a4fdb4 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -4,6 +4,7 @@ import { MessageRemovedEvent, MessagePartUpdatedEvent, MessagePartRemovedEvent, + MessagePartDeltaEvent, } from "../types/message" import type { EventLspUpdated, @@ -58,6 +59,7 @@ type SSEEvent = | MessageRemovedEvent | MessagePartUpdatedEvent | MessagePartRemovedEvent + | MessagePartDeltaEvent | EventSessionUpdated | EventSessionCompacted | EventSessionDiff @@ -118,6 +120,9 @@ class SSEManager { case "message.part.updated": this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent) break + case "message.part.delta": + this.onMessagePartDelta?.(instanceId, event as MessagePartDeltaEvent) + break case "message.removed": this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent) break @@ -184,6 +189,7 @@ class SSEManager { onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void + onMessagePartDelta?: (instanceId: string, event: MessagePartDeltaEvent) => void onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index a5f1afa6..4616bc94 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -1,11 +1,11 @@ -import type { AppConfig, InstanceData } from "../../../server/src/api-types" +import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { serverEvents } from "./server-events" import { getLogger } from "./logger" const log = getLogger("actions") -export type ConfigData = AppConfig +export type OwnerBucket = Record const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], @@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean { } export class ServerStorage { - private configChangeListeners: Set<(config: ConfigData) => void> = new Set() - private configCache: ConfigData | null = null - private loadPromise: Promise | null = null + private configOwnerCache = new Map() + private stateOwnerCache = new Map() + private configOwnerLoadPromises = new Map>() + private stateOwnerLoadPromises = new Map>() + private configOwnerListeners = new Map void>>() + private stateOwnerListeners = new Map void>>() private instanceDataCache = new Map() private instanceDataListeners = new Map void>>() private instanceLoadPromises = new Map>() constructor() { - serverEvents.on("config.appChanged", (event) => { - if (event.type !== "config.appChanged") return - this.setConfigCache(event.config) + serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.configChanged") return + this.setOwnerCache("config", event.owner, event.value) + }) + + serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => { + if (event.type !== "storage.stateChanged") return + this.setOwnerCache("state", event.owner, event.value) }) serverEvents.on("instance.dataChanged", (event) => { @@ -49,30 +57,56 @@ export class ServerStorage { }) } - async loadConfig(): Promise { - if (this.configCache) { - return this.configCache - } + async loadConfigOwner(owner: string): Promise { + const cached = this.configOwnerCache.get(owner) + if (cached) return cached - if (!this.loadPromise) { - this.loadPromise = serverApi - .fetchConfig() - .then((config) => { - this.setConfigCache(config) - return config + if (!this.configOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchConfigOwner(owner) + .then((value) => { + this.setOwnerCache("config", owner, value) + return value }) .finally(() => { - this.loadPromise = null + this.configOwnerLoadPromises.delete(owner) }) + this.configOwnerLoadPromises.set(owner, promise) } - return this.loadPromise + return this.configOwnerLoadPromises.get(owner)! } - async updateConfig(next: ConfigData): Promise { - const nextConfig = await serverApi.updateConfig(next) - this.setConfigCache(nextConfig) - return nextConfig + async patchConfigOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchConfigOwner(owner, patch) + this.setOwnerCache("config", owner, updated) + return updated + } + + async loadStateOwner(owner: string): Promise { + const cached = this.stateOwnerCache.get(owner) + if (cached) return cached + + if (!this.stateOwnerLoadPromises.has(owner)) { + const promise = serverApi + .fetchStateOwner(owner) + .then((value) => { + this.setOwnerCache("state", owner, value) + return value + }) + .finally(() => { + this.stateOwnerLoadPromises.delete(owner) + }) + this.stateOwnerLoadPromises.set(owner, promise) + } + + return this.stateOwnerLoadPromises.get(owner)! + } + + async patchStateOwner(owner: string, patch: unknown): Promise { + const updated = await serverApi.patchStateOwner(owner, patch) + this.setOwnerCache("state", owner, updated) + return updated } async loadInstanceData(instanceId: string): Promise { @@ -110,12 +144,40 @@ export class ServerStorage { this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) } - onConfigChanged(listener: (config: ConfigData) => void): () => void { - this.configChangeListeners.add(listener) - if (this.configCache) { - listener(this.configCache) + onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.configOwnerListeners.has(owner)) { + this.configOwnerListeners.set(owner, new Set()) + } + const bucket = this.configOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.configOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.configOwnerListeners.delete(owner) + } + } + } + + onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { + if (!this.stateOwnerListeners.has(owner)) { + this.stateOwnerListeners.set(owner, new Set()) + } + const bucket = this.stateOwnerListeners.get(owner)! + bucket.add(listener) + const cached = this.stateOwnerCache.get(owner) + if (cached) { + listener(cached) + } + return () => { + bucket.delete(listener) + if (bucket.size === 0) { + this.stateOwnerListeners.delete(owner) + } } - return () => this.configChangeListeners.delete(listener) } onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void { @@ -136,18 +198,30 @@ export class ServerStorage { } } - private setConfigCache(config: ConfigData) { - if (this.configCache && isDeepEqual(this.configCache, config)) { - this.configCache = config + private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) { + if (owner === "*") { + // Full-doc updates are not tracked owner-by-owner; invalidate caches. + if (kind === "config") { + this.configOwnerCache.clear() + } else { + this.stateOwnerCache.clear() + } return } - this.configCache = config - this.notifyConfigChanged(config) - } - private notifyConfigChanged(config: ConfigData) { - for (const listener of this.configChangeListeners) { - listener(config) + const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache + const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners + + const previous = cache.get(owner) + if (previous && isDeepEqual(previous, value)) { + cache.set(owner, value) + return + } + cache.set(owner, value) + const bucket = listeners.get(owner) + if (!bucket) return + for (const listener of bucket) { + listener(value) } } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4ab54714..c0a1b4ff 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -30,8 +30,8 @@ async function bootstrap() { document.documentElement.removeAttribute("data-theme") try { - const config = await storage.loadConfig() - const theme = config?.theme ?? "system" + const uiConfig = await storage.loadConfigOwner("ui") + const theme = (uiConfig as any)?.theme ?? "system" if (theme === "system") { document.documentElement.removeAttribute("data-theme") diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f3e5c56e..f2a39bda 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -20,7 +20,7 @@ import { } from "./sessions" import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { fetchCommands, clearCommands } from "./commands" -import { preferences } from "./preferences" +import { serverSettings } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" @@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath, binaryLabel: descriptor.binaryLabel, binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion, - environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, + environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {}, } } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 65e20bd8..ea566bf9 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -77,9 +77,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined store.upsertMessage({ id: info.id, @@ -87,7 +87,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null role: info.role === "user" ? "user" : "assistant", status: options?.status ?? "complete", createdAt, - updatedAt: completedAt ?? createdAt, + updatedAt: endAt ?? createdAt, bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) @@ -104,6 +104,22 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | }) } +export function applyPartDeltaV2( + instanceId: string, + input: { messageId: string; partId: string; field: string; delta: string }, +): void { + if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { + return + } + const store = messageStoreBus.getOrCreate(instanceId) + store.applyPartDelta({ + messageId: input.messageId, + partId: input.partId, + field: input.field, + delta: input.delta, + }) +} + export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void { if (!oldId || !newId || oldId === newId) return const store = messageStoreBus.getOrCreate(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 0ebc3c8b..1bebb023 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -189,6 +189,7 @@ export interface InstanceMessageStore { hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void + applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void removeMessage: (messageId: string) => void removeMessagePart: (messageId: string, partId: string) => void bufferPendingPart: (entry: PendingPartEntry) => void @@ -597,6 +598,45 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt bumpSessionRevision(message.sessionId) } + function applyPartDelta(input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) { + if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { + return + } + + const message = state.messages[input.messageId] + if (!message) { + // Best-effort: drop deltas for unknown messages. + return + } + + let applied = false + + setState( + "messages", + input.messageId, + produce((draft: MessageRecord) => { + const entry = draft.parts[input.partId] + if (!entry?.data) return + const part = entry.data as any + const currentValue = part?.[input.field] + if (typeof currentValue === "string" || currentValue === undefined || currentValue === null) { + part[input.field] = `${currentValue ?? ""}${input.delta}` + applied = true + } + if (!applied) return + entry.revision += 1 + draft.updatedAt = Date.now() + if (input.bumpRevision ?? true) { + draft.revision += 1 + } + }), + ) + + if (applied) { + bumpSessionRevision(message.sessionId) + } + } + function removeMessage(messageId: string) { if (!messageId) return @@ -1087,19 +1127,20 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState(reconcile(createInitialState(instanceId))) } - return { + return { instanceId, state, setState, addOrUpdateSession, - hydrateMessages, - upsertMessage, + hydrateMessages, + upsertMessage, applyPartUpdate, + applyPartDelta, removeMessage, removeMessagePart, bufferPendingPart, - flushPendingParts, + flushPendingParts, replaceMessageId, setMessageInfo, getMessageInfo, @@ -1125,4 +1166,3 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } } - diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5907e78e..96ec386a 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { storage, type ConfigData } from "../lib/storage" +import { storage, type OwnerBucket } from "../lib/storage" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -23,32 +23,22 @@ export interface ModelPreference { modelId: string } -export interface AgentModelSelections { - [instanceId: string]: Record -} - export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" - export type ListeningMode = "local" | "all" -export interface Preferences { +export interface UiSettings { showThinkingBlocks: boolean + showKeyboardShortcutHints: boolean thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean promptSubmitOnEnter: boolean - lastUsedBinary?: string locale?: string - environmentVariables: Record - modelRecents: ModelPreference[] - modelFavorites: ModelPreference[] - modelThinkingSelections: Record diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference showUsageMetrics: boolean autoCleanupBlankSessions: boolean - listeningMode: ListeningMode // OS notifications osNotificationsEnabled: boolean @@ -57,12 +47,14 @@ export interface Preferences { notifyOnIdle: boolean } +// Backwards-compatible alias for older imports. +export type Preferences = UiSettings export interface OpenCodeBinary { - path: string version?: string lastUsed: number + label?: string } export interface RecentFolder { @@ -70,27 +62,54 @@ export interface RecentFolder { lastAccessed: number } -export type ThemePreference = NonNullable +export type ThemePreference = "light" | "dark" | "system" + +interface UiConfigBucket { + theme?: ThemePreference + settings?: Partial +} + +interface ServerConfigBucket { + listeningMode?: ListeningMode + environmentVariables?: Record + opencodeBinary?: string +} + +interface UiStateBucket { + recentFolders?: RecentFolder[] + opencodeBinaries?: OpenCodeBinary[] + models?: { + recents?: ModelPreference[] + favorites?: ModelPreference[] + thinkingSelections?: Record + } +} + +interface NormalizedUiState { + recentFolders: RecentFolder[] + opencodeBinaries: OpenCodeBinary[] + models: { + recents: ModelPreference[] + favorites: ModelPreference[] + thinkingSelections: Record + } +} const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_MODELS = 5 const MAX_FAVORITE_MODELS = 50 -const defaultPreferences: Preferences = { +const defaultUiSettings: UiSettings = { showThinkingBlocks: false, + showKeyboardShortcutHints: true, thinkingBlocksExpansion: "expanded", showTimelineTools: true, promptSubmitOnEnter: false, - environmentVariables: {}, - modelRecents: [], - modelFavorites: [], - modelThinkingSelections: {}, diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", showUsageMetrics: true, autoCleanupBlankSessions: true, - listeningMode: "local", osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -98,382 +117,357 @@ const defaultPreferences: Preferences = { notifyOnIdle: true, } - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { - try { - return JSON.stringify(a) === JSON.stringify(b) - } catch (error) { - log.warn("Failed to compare preference values", error) - } +function normalizeUiSettings(input?: Partial | null): UiSettings { + const sanitized = input ?? {} + return { + showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultUiSettings.showThinkingBlocks, + showKeyboardShortcutHints: + sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints, + thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, + showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, + promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, + locale: sanitized.locale ?? defaultUiSettings.locale, + diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, + toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, + diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, + autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, + osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, + osNotificationsAllowWhenVisible: + sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, + notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultUiSettings.notifyOnNeedsInput, + notifyOnIdle: sanitized.notifyOnIdle ?? defaultUiSettings.notifyOnIdle, } - return false } -function normalizePreferences(pref?: Partial & { agentModelSelections?: unknown }): Preferences { - const sanitized = pref ?? {} - const environmentVariables = { - ...defaultPreferences.environmentVariables, - ...(sanitized.environmentVariables ?? {}), +function normalizeRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "string") out[k] = v } + return out +} - const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents - const modelRecents = sourceModelRecents.map((item) => ({ ...item })) - - const sourceModelFavorites = sanitized.modelFavorites ?? defaultPreferences.modelFavorites - const modelFavorites = sourceModelFavorites.map((item) => ({ ...item })) - - const modelThinkingSelections = { - ...defaultPreferences.modelThinkingSelections, - ...(sanitized.modelThinkingSelections ?? {}), +function cloneArray(value: unknown, mapper: (item: any) => T | null): T[] { + if (!Array.isArray(value)) return [] + const out: T[] = [] + for (const item of value) { + const mapped = mapper(item) + if (mapped) out.push(mapped) } + return out +} +function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { + const source = input ?? {} return { - showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, - thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, - showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, - promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter, - lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, - locale: sanitized.locale ?? defaultPreferences.locale, - environmentVariables, - modelRecents, - modelFavorites, - modelThinkingSelections, - diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, - toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, - diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, - showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, - autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, - listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, - - osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled, - osNotificationsAllowWhenVisible: - sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible, - notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput, - notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle, + recentFolders: cloneArray(source.recentFolders, (f) => { + if (!f || typeof f !== "object") return null + const p = (f as any).path + const lastAccessed = (f as any).lastAccessed + if (typeof p !== "string") return null + const ts = typeof lastAccessed === "number" ? lastAccessed : Date.now() + return { path: p, lastAccessed: ts } + }), + opencodeBinaries: cloneArray(source.opencodeBinaries, (b) => { + if (!b || typeof b !== "object") return null + const p = (b as any).path + if (typeof p !== "string") return null + const lastUsed = typeof (b as any).lastUsed === "number" ? (b as any).lastUsed : Date.now() + const version = typeof (b as any).version === "string" ? (b as any).version : undefined + const label = typeof (b as any).label === "string" ? (b as any).label : undefined + return { path: p, version, label, lastUsed } + }), + models: { + recents: cloneArray((source.models as any)?.recents, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + favorites: cloneArray((source.models as any)?.favorites, (m) => { + if (!m || typeof m !== "object") return null + const providerId = (m as any).providerId + const modelId = (m as any).modelId + if (typeof providerId !== "string" || typeof modelId !== "string") return null + return { providerId, modelId } + }), + thinkingSelections: normalizeRecord((source.models as any)?.thinkingSelections), + }, } } +function normalizeServerConfig(input?: ServerConfigBucket | null): Required> { + const source = input ?? {} + const listeningMode = source.listeningMode === "all" ? "all" : "local" + const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" + const environmentVariables = normalizeRecord(source.environmentVariables) + return { listeningMode, opencodeBinary, environmentVariables } +} + function getModelKey(model: { providerId: string; modelId: string }): string { return `${model.providerId}/${model.modelId}` } +function buildRecentFolderList(folderPath: string, source: RecentFolder[]): RecentFolder[] { + const folders = source.filter((f) => f.path !== folderPath) + folders.unshift({ path: folderPath, lastAccessed: Date.now() }) + return folders.slice(0, MAX_RECENT_FOLDERS) +} + +function buildBinaryList(binaryPath: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { + const timestamp = Date.now() + const existing = source.find((b) => b.path === binaryPath) + if (existing) { + const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp, version: version ?? existing.version } + const remaining = source.filter((b) => b.path !== binaryPath) + return [updatedEntry, ...remaining] + } + const nextEntry: OpenCodeBinary = version + ? { path: binaryPath, version, lastUsed: timestamp } + : { path: binaryPath, lastUsed: timestamp } + return [nextEntry, ...source].slice(0, 10) +} + +const [uiConfigBucket, setUiConfigBucket] = createSignal({}) +const [serverConfigBucket, setServerConfigBucket] = createSignal({}) +const [uiStateBucket, setUiStateBucket] = createSignal({}) +const [isLoaded, setIsLoaded] = createSignal(false) + +const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) +const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") +const serverSettings = createMemo(() => normalizeServerConfig(serverConfigBucket())) +const uiState = createMemo(() => normalizeUiState(uiStateBucket())) + +const preferences = uiSettings +const recentFolders = createMemo(() => uiState().recentFolders) +const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) + +let loadPromise: Promise | null = null + +async function ensureLoaded(): Promise { + if (isLoaded()) return + if (!loadPromise) { + loadPromise = Promise.all([ + storage.loadConfigOwner("ui"), + storage.loadConfigOwner("server"), + storage.loadStateOwner("ui"), + ]) + .then(([uiCfg, srvCfg, uiSt]) => { + setUiConfigBucket(uiCfg as any) + setServerConfigBucket(srvCfg as any) + setUiStateBucket(uiSt as any) + setIsLoaded(true) + }) + .catch((error) => { + log.error("Failed to load settings", error) + setUiConfigBucket({}) + setServerConfigBucket({}) + setUiStateBucket({}) + setIsLoaded(true) + }) + .finally(() => { + loadPromise = null + }) + } + await loadPromise +} + +async function patchConfigOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchConfigOwner(owner, patch) + if (owner === "ui") setUiConfigBucket(updated as any) + if (owner === "server") setServerConfigBucket(updated as any) +} + +async function patchStateOwner(owner: string, patch: unknown) { + await ensureLoaded() + const updated = await storage.patchStateOwner(owner, patch) + if (owner === "ui") setUiStateBucket(updated as any) +} + +function updateUiSettings(updates: Partial) { + const current = uiConfigBucket() + const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) + const patch = { settings: nextSettings } + void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) +} + +function updatePreferences(updates: Partial): void { + updateUiSettings(updates) +} + +function setThemePreference(preference: ThemePreference): void { + if (themePreference() === preference) return + void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) +} + +function setListeningMode(mode: ListeningMode): void { + if (serverSettings().listeningMode === mode) return + void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error)) +} + +function updateEnvironmentVariables(envVars: Record): void { + void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => + log.error("Failed to update environment variables", error), + ) +} + +function addEnvironmentVariable(key: string, value: string): void { + const current = serverSettings().environmentVariables + updateEnvironmentVariables({ ...current, [key]: value }) +} + +function removeEnvironmentVariable(key: string): void { + const current = serverSettings().environmentVariables + const { [key]: removed, ...rest } = current + updateEnvironmentVariables(rest) +} + +function updateLastUsedBinary(path: string): void { + const target = path && path.trim().length > 0 ? path : "opencode" + void patchConfigOwner("server", { opencodeBinary: target }).catch((error) => log.error("Failed to set default binary", error)) + + // also bump lastUsed in state ui.opencodeBinaries + const nextList = buildBinaryList(target, undefined, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) +} + +function addOpenCodeBinary(path: string, version?: string): void { + const nextList = buildBinaryList(path, version, opencodeBinaries()) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error)) +} + +function removeOpenCodeBinary(path: string): void { + const nextList = opencodeBinaries().filter((b) => b.path !== path) + void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to remove binary", error)) + + if (serverSettings().opencodeBinary === path) { + void patchConfigOwner("server", { opencodeBinary: "opencode" }).catch((error) => + log.error("Failed to reset default binary", error), + ) + } +} + +function addRecentFolder(folderPath: string): void { + const next = buildRecentFolderList(folderPath, recentFolders()) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to add recent folder", error)) +} + +function removeRecentFolder(folderPath: string): void { + const next = recentFolders().filter((f) => f.path !== folderPath) + void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error)) +} + +function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { + const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary + const nextFolders = buildRecentFolderList(folderPath, recentFolders()) + const nextBinaries = buildBinaryList(targetBinary, undefined, opencodeBinaries()) + + void patchStateOwner("ui", { recentFolders: nextFolders, opencodeBinaries: nextBinaries }).catch((error) => + log.error("Failed to update ui state on launch", error), + ) + void patchConfigOwner("server", { opencodeBinary: targetBinary }).catch((error) => + log.error("Failed to persist selected binary", error), + ) +} + +function addRecentModelPreference(model: ModelPreference): void { + if (!model.providerId || !model.modelId) return + const recents = uiState().models.recents + const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) + void patchStateOwner("ui", { models: { recents: updated } }).catch((error) => log.error("Failed to update model recents", error)) +} + function isFavoriteModelPreference(model: ModelPreference): boolean { if (!model.providerId || !model.modelId) return false - return (preferences().modelFavorites ?? []).some( - (item) => item.providerId === model.providerId && item.modelId === model.modelId, - ) + return uiState().models.favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) } function toggleFavoriteModelPreference(model: ModelPreference): void { if (!model.providerId || !model.modelId) return - const favorites = preferences().modelFavorites ?? [] + const favorites = uiState().models.favorites const exists = favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId) - if (exists) { - const updated = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - updatePreferences({ modelFavorites: updated }) - return - } + const updated = exists + ? favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) + : [model, ...favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)].slice( + 0, + MAX_FAVORITE_MODELS, + ) - const filtered = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_FAVORITE_MODELS) - updatePreferences({ modelFavorites: updated }) + void patchStateOwner("ui", { models: { favorites: updated } }).catch((error) => log.error("Failed to update model favorites", error)) } function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined { if (!model.providerId || !model.modelId) return undefined - return preferences().modelThinkingSelections?.[getModelKey(model)] + return uiState().models.thinkingSelections[getModelKey(model)] } function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void { if (!model.providerId || !model.modelId) return const key = getModelKey(model) - const current = preferences().modelThinkingSelections?.[key] + const current = uiState().models.thinkingSelections[key] if (current === value) return - updateConfig((draft) => { - const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) } - if (!value) { - delete selections[key] - } else { - selections[key] = value - } - draft.preferences = normalizePreferences({ - ...draft.preferences, - modelThinkingSelections: selections, - }) - }) -} - -const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) - -const config = createMemo>(() => internalConfig()) -const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) -const preferences = createMemo(() => internalConfig().preferences) -const recentFolders = createMemo(() => internalConfig().recentFolders ?? []) -const opencodeBinaries = createMemo(() => internalConfig().opencodeBinaries ?? []) -const themePreference = createMemo(() => internalConfig().theme ?? "system") -let loadPromise: Promise | null = null - -function normalizeConfig(config?: ConfigData | null): ConfigData { - return { - preferences: normalizePreferences(config?.preferences), - recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })), - opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })), - theme: config?.theme ?? "system", + const selections = { ...uiState().models.thinkingSelections } + if (!value) { + delete selections[key] + } else { + selections[key] = value } -} - -function buildFallbackConfig(): ConfigData { - return normalizeConfig() -} - -function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } { - const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections) - const cleanedConfig = normalizeConfig(config) - return { cleaned: cleanedConfig, migrated } -} - -async function syncConfig(source?: ConfigData): Promise { - try { - const loaded = source ?? (await storage.loadConfig()) - const { cleaned, migrated } = removeLegacyAgentSelections(loaded) - applyConfig(cleaned) - if (migrated) { - void storage.updateConfig(cleaned).catch((error: unknown) => { - log.error("Failed to persist legacy config cleanup", error) - }) - } - } catch (error) { - log.error("Failed to load config", error) - applyConfig(buildFallbackConfig()) - } -} - -function applyConfig(next: ConfigData) { - setInternalConfig(normalizeConfig(next)) - setIsConfigLoaded(true) -} - -function cloneConfigForUpdate(): ConfigData { - return normalizeConfig(internalConfig()) -} - -function logConfigDiff(previous: ConfigData, next: ConfigData) { - if (deepEqual(previous, next)) { - return - } - const changes = diffObjects(previous, next) - if (changes.length > 0) { - log.info("[Config] Changes", changes) - } -} - -function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] { - if (previous === next) { - return [] - } - - if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) { - return [path.join(".")] - } - - const prevKeys = Object.keys(previous as Record) - const nextKeys = Object.keys(next as Record) - const allKeys = new Set([...prevKeys, ...nextKeys]) - const changes: string[] = [] - - for (const key of allKeys) { - const childPath = [...path, key] - const prevValue = (previous as Record)[key] - const nextValue = (next as Record)[key] - changes.push(...diffObjects(prevValue, nextValue, childPath)) - } - - return changes -} - -function updateConfig(mutator: (draft: ConfigData) => void): void { - const previous = internalConfig() - const draft = cloneConfigForUpdate() - mutator(draft) - logConfigDiff(previous, draft) - applyConfig(draft) - void persistFullConfig(draft) -} - -async function persistFullConfig(next: ConfigData): Promise { - try { - await ensureConfigLoaded() - await storage.updateConfig(next) - } catch (error) { - log.error("Failed to save config", error) - void syncConfig().catch((syncError: unknown) => { - log.error("Failed to refresh config", syncError) - }) - } -} - -function setThemePreference(preference: ThemePreference): void { - if (themePreference() === preference) { - return - } - updateConfig((draft) => { - draft.theme = preference - }) -} - -async function ensureConfigLoaded(): Promise { - if (isConfigLoaded()) return - if (!loadPromise) { - loadPromise = syncConfig().finally(() => { - loadPromise = null - }) - } - await loadPromise -} - -function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] { - const folders = source.filter((f) => f.path !== path) - folders.unshift({ path, lastAccessed: Date.now() }) - return folders.slice(0, MAX_RECENT_FOLDERS) -} - -function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { - const timestamp = Date.now() - const existing = source.find((b) => b.path === path) - if (existing) { - const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp } - const remaining = source.filter((b) => b.path !== path) - return [updatedEntry, ...remaining] - } - const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp } - return [nextEntry, ...source].slice(0, 10) -} - -function updatePreferences(updates: Partial): void { - const current = internalConfig().preferences - const merged = normalizePreferences({ ...current, ...updates }) - if (deepEqual(current, merged)) { - return - } - updateConfig((draft) => { - draft.preferences = merged - }) -} - -function setListeningMode(mode: ListeningMode): void { - if (preferences().listeningMode === mode) return - updatePreferences({ listeningMode: mode }) + void patchStateOwner("ui", { models: { thinkingSelections: selections } }).catch((error) => + log.error("Failed to update thinking selection", error), + ) } function setDiffViewMode(mode: DiffViewMode): void { if (preferences().diffViewMode === mode) return - updatePreferences({ diffViewMode: mode }) + updateUiSettings({ diffViewMode: mode }) } function setToolOutputExpansion(mode: ExpansionPreference): void { if (preferences().toolOutputExpansion === mode) return - updatePreferences({ toolOutputExpansion: mode }) + updateUiSettings({ toolOutputExpansion: mode }) } function setDiagnosticsExpansion(mode: ExpansionPreference): void { if (preferences().diagnosticsExpansion === mode) return - updatePreferences({ diagnosticsExpansion: mode }) + updateUiSettings({ diagnosticsExpansion: mode }) } function setThinkingBlocksExpansion(mode: ExpansionPreference): void { if (preferences().thinkingBlocksExpansion === mode) return - updatePreferences({ thinkingBlocksExpansion: mode }) + updateUiSettings({ thinkingBlocksExpansion: mode }) } function toggleShowThinkingBlocks(): void { - updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) + updateUiSettings({ showThinkingBlocks: !preferences().showThinkingBlocks }) +} + +function toggleKeyboardShortcutHints(): void { + updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints }) } function toggleShowTimelineTools(): void { - updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) + updateUiSettings({ showTimelineTools: !preferences().showTimelineTools }) } function toggleUsageMetrics(): void { - updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics }) + updateUiSettings({ showUsageMetrics: !preferences().showUsageMetrics }) } function togglePromptSubmitOnEnter(): void { - updatePreferences({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) + updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) } function toggleAutoCleanupBlankSessions(): void { const nextValue = !preferences().autoCleanupBlankSessions log.info("toggle auto cleanup", { value: nextValue }) - updatePreferences({ autoCleanupBlankSessions: nextValue }) -} - -function addRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) - }) -} - -function removeRecentFolder(path: string): void { - updateConfig((draft) => { - draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path) - }) -} - -function addOpenCodeBinary(path: string, version?: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries) - }) -} - -function removeOpenCodeBinary(path: string): void { - updateConfig((draft) => { - draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path) - }) -} - -function updateLastUsedBinary(path: string): void { - const target = path || preferences().lastUsedBinary || "opencode" - updateConfig((draft) => { - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target }) - draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries) - }) -} - -function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { - updateConfig((draft) => { - const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode" - draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders) - draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary }) - draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries) - }) -} - -function updateEnvironmentVariables(envVars: Record): void { - updatePreferences({ environmentVariables: envVars }) -} - -function addEnvironmentVariable(key: string, value: string): void { - const current = preferences().environmentVariables || {} - const updated = { ...current, [key]: value } - updateEnvironmentVariables(updated) -} - -function removeEnvironmentVariable(key: string): void { - const current = preferences().environmentVariables || {} - const { [key]: removed, ...rest } = current - updateEnvironmentVariables(rest) -} - -function addRecentModelPreference(model: ModelPreference): void { - if (!model.providerId || !model.modelId) return - const recents = preferences().modelRecents ?? [] - const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) - const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) - updatePreferences({ modelRecents: updated }) + updateUiSettings({ autoCleanupBlankSessions: nextValue }) } async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise { @@ -497,41 +491,53 @@ async function getAgentModelPreference(instanceId: string, agent: string): Promi return selections[agent] } -void ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) +void ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) interface ConfigContextValue { isLoaded: Accessor - config: typeof config preferences: typeof preferences - recentFolders: typeof recentFolders - opencodeBinaries: typeof opencodeBinaries + updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference - updateConfig: typeof updateConfig - toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks - toggleShowTimelineTools: typeof toggleShowTimelineTools - toggleUsageMetrics: typeof toggleUsageMetrics - toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions - togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter - setDiffViewMode: typeof setDiffViewMode - setToolOutputExpansion: typeof setToolOutputExpansion - setDiagnosticsExpansion: typeof setDiagnosticsExpansion - setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion + // server-owned stable config + serverSettings: typeof serverSettings setListeningMode: typeof setListeningMode + updateEnvironmentVariables: typeof updateEnvironmentVariables + addEnvironmentVariable: typeof addEnvironmentVariable + removeEnvironmentVariable: typeof removeEnvironmentVariable + updateLastUsedBinary: typeof updateLastUsedBinary + + // ui-owned state + recentFolders: typeof recentFolders + opencodeBinaries: typeof opencodeBinaries + uiState: typeof uiState addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary - updateLastUsedBinary: typeof updateLastUsedBinary recordWorkspaceLaunch: typeof recordWorkspaceLaunch - updatePreferences: typeof updatePreferences - updateEnvironmentVariables: typeof updateEnvironmentVariables - addEnvironmentVariable: typeof addEnvironmentVariable - removeEnvironmentVariable: typeof removeEnvironmentVariable addRecentModelPreference: typeof addRecentModelPreference + isFavoriteModelPreference: typeof isFavoriteModelPreference + toggleFavoriteModelPreference: typeof toggleFavoriteModelPreference + getModelThinkingSelection: typeof getModelThinkingSelection + setModelThinkingSelection: typeof setModelThinkingSelection + + // ui settings helpers + toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks + toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints + toggleShowTimelineTools: typeof toggleShowTimelineTools + toggleUsageMetrics: typeof toggleUsageMetrics + toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions + togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter + setDiffViewMode: typeof setDiffViewMode + setToolOutputExpansion: typeof setToolOutputExpansion + setDiagnosticsExpansion: typeof setDiagnosticsExpansion + setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion + + // instance scoped setAgentModelPreference: typeof setAgentModelPreference getAgentModelPreference: typeof getAgentModelPreference } @@ -539,15 +545,32 @@ interface ConfigContextValue { const ConfigContext = createContext() const configContextValue: ConfigContextValue = { - isLoaded: isConfigLoaded, - config, + isLoaded, preferences, - recentFolders, - opencodeBinaries, + updatePreferences, themePreference, setThemePreference, - updateConfig, + serverSettings, + setListeningMode, + updateEnvironmentVariables, + addEnvironmentVariable, + removeEnvironmentVariable, + updateLastUsedBinary, + recentFolders, + opencodeBinaries, + uiState, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, + addRecentModelPreference, + isFavoriteModelPreference, + toggleFavoriteModelPreference, + getModelThinkingSelection, + setModelThinkingSelection, toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, toggleShowTimelineTools, toggleUsageMetrics, toggleAutoCleanupBlankSessions, @@ -556,43 +579,40 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - addRecentFolder, - removeRecentFolder, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, - recordWorkspaceLaunch, - updatePreferences, - updateEnvironmentVariables, - addEnvironmentVariable, - removeEnvironmentVariable, - addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, } -const ConfigProvider: ParentComponent = (props) => { +export const ConfigProvider: ParentComponent = (props) => { onMount(() => { - ensureConfigLoaded().catch((error: unknown) => { - log.error("Failed to initialize config", error) + ensureLoaded().catch((error: unknown) => { + log.error("Failed to initialize settings", error) }) - const unsubscribe = storage.onConfigChanged((config) => { - syncConfig(config).catch((error: unknown) => { - log.error("Failed to refresh config", error) - }) + const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { + setUiConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { + setServerConfigBucket(bucket as any) + setIsLoaded(true) + }) + const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => { + setUiStateBucket(bucket as any) + setIsLoaded(true) }) return () => { - unsubscribe() + unsubUi() + unsubServer() + unsubStateUi() } }) return {props.children} } -function useConfig(): ConfigContextValue { +export function useConfig(): ConfigContextValue { const context = useContext(ConfigContext) if (!context) { throw new Error("useConfig must be used within ConfigProvider") @@ -601,41 +621,39 @@ function useConfig(): ConfigContextValue { } export { - ConfigProvider, - useConfig, - config, preferences, - updateConfig, - updatePreferences, - toggleShowThinkingBlocks, - toggleShowTimelineTools, - toggleAutoCleanupBlankSessions, - toggleUsageMetrics, - togglePromptSubmitOnEnter, + uiState, + serverSettings, recentFolders, - addRecentFolder, - removeRecentFolder, opencodeBinaries, - addOpenCodeBinary, - removeOpenCodeBinary, - updateLastUsedBinary, + themePreference, + setThemePreference, + updatePreferences, + setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, + updateLastUsedBinary, + addRecentFolder, + removeRecentFolder, + addOpenCodeBinary, + removeOpenCodeBinary, + recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, toggleFavoriteModelPreference, getModelThinkingSelection, setModelThinkingSelection, - setAgentModelPreference, - getAgentModelPreference, + toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, + toggleShowTimelineTools, + toggleUsageMetrics, + toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter, setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, - setListeningMode, - themePreference, - setThemePreference, - recordWorkspaceLaunch, - } - + setAgentModelPreference, + getAgentModelPreference, +} diff --git a/packages/ui/src/stores/releases.ts b/packages/ui/src/stores/releases.ts index d02bd4b3..39c199d2 100644 --- a/packages/ui/src/stores/releases.ts +++ b/packages/ui/src/stores/releases.ts @@ -11,12 +11,15 @@ const log = getLogger("actions") const [supportInfo, setSupportInfo] = createSignal(null) 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 visibilityEffectInitialized = false let activeToast: ToastHandle | null = null let activeToastKey: string | null = null let uiUpdateToasted = false +let metaRefreshInterval: ReturnType | null = null function dismissActiveToast() { if (activeToast) { @@ -80,6 +83,8 @@ async function refreshFromMeta() { const meta = await getServerMeta(true) setSupportInfo(meta.support ?? null) maybeNotifyUiUpdated(meta) + maybeNotifyDevReleaseAvailable(meta) + ensureMetaRefresh(meta) } catch (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 { try { if (typeof window === "undefined" || !window.localStorage) return null diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 4771fae3..456f5456 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -140,8 +140,11 @@ async function sendMessage( const display: string | undefined = att.display const value: unknown = source.value const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display) + const isPathPlaceholder = typeof display === "string" && /^path:/.test(display) - if (isPastedPlaceholder || typeof value !== "string") { + // Skip path: attachments from being sent as separate parts (content is already in prompt) + // Skip pasted placeholders too (already resolved in prompt) + if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") { continue } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f5516a8a..58d2a64a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -1,6 +1,7 @@ import type { MessageInfo, MessagePartRemovedEvent, + MessagePartDeltaEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent, @@ -48,6 +49,7 @@ import { loadMessages } from "./session-api" import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" import { applyPartUpdateV2, + applyPartDeltaV2, replaceMessageIdV2, reconcilePendingQuestionsV2, upsertMessageInfoV2, @@ -298,10 +300,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } const nextUpdated = - typeof timeInfo.completed === "number" && timeInfo.completed > 0 - ? timeInfo.completed + typeof timeInfo.end === "number" && timeInfo.end > 0 + ? timeInfo.end : typeof timeInfo.updated === "number" && timeInfo.updated > 0 ? timeInfo.updated : typeof timeInfo.created === "number" && timeInfo.created > 0 @@ -331,14 +333,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const completedAt = (info.time as { completed?: number } | undefined)?.completed + const endAt = (info.time as { end?: number } | undefined)?.end store.upsertMessage({ id: messageId, sessionId, role, status, createdAt, - updatedAt: completedAt ?? createdAt, + updatedAt: endAt ?? createdAt, }) } @@ -348,6 +350,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes } } +function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void { + const props = event.properties + if (!props) return + const { messageID, partID, field, delta } = props + if (!messageID || !partID || !field || typeof delta !== "string") return + applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta }) +} + function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { const info = event.properties?.info @@ -625,6 +635,7 @@ function handleQuestionAnswered( export { handleMessagePartRemoved, handleMessageRemoved, + handleMessagePartDelta, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, diff --git a/packages/ui/src/stores/session-models.ts b/packages/ui/src/stores/session-models.ts index d03fd423..919e8a9e 100644 --- a/packages/ui/src/stores/session-models.ts +++ b/packages/ui/src/stores/session-models.ts @@ -1,5 +1,5 @@ import { agents, providers } from "./session-state" -import { preferences, getAgentModelPreference } from "./preferences" +import { uiState, getAgentModelPreference } from "./preferences" const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 @@ -17,7 +17,7 @@ function isModelValid( function getRecentModelPreferenceForInstance( instanceId: string, ): { providerId: string; modelId: string } | undefined { - const recents = preferences().modelRecents ?? [] + const recents = uiState().models.recents ?? [] for (const item of recents) { if (isModelValid(instanceId, item)) { return item diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index ef056a0e..8b5e9a3e 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -58,6 +58,7 @@ import { import { handleMessagePartRemoved, handleMessageRemoved, + handleMessagePartDelta, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, @@ -74,6 +75,7 @@ import { sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessagePartUpdated = handleMessageUpdate +sseManager.onMessagePartDelta = handleMessagePartDelta sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index dbeea6dd..142e8e68 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -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 { .markdown-body { @@ -108,17 +108,23 @@ background: transparent; } - .markdown-body pre { + .markdown-body pre:not(.shiki) { font-family: var(--font-family-mono); font-size: var(--font-size-sm); line-height: var(--line-height-normal); background-color: var(--surface-code); + color: var(--text-primary); border: 1px solid var(--border-base); border-radius: 8px; padding: 0.75rem; margin: 1rem 0; } + .markdown-body pre:not(.shiki) code, + .markdown-code-block pre:not(.shiki) code { + color: var(--text-primary); + } + .markdown-body blockquote { border-left: 3px solid var(--border-base); color: var(--text-secondary); @@ -151,16 +157,6 @@ width: 100%; margin: 1rem 0; 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, @@ -168,12 +164,22 @@ border: 1px solid var(--border-base); padding: 0.5rem 0.75rem; text-align: left; + color: var(--text-primary); + background-color: transparent; } .markdown-body th { 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 { position: relative; margin: 10px 0; diff --git a/packages/ui/src/styles/panels/modal.css b/packages/ui/src/styles/panels/modal.css index 2fa598b5..4edc3b81 100644 --- a/packages/ui/src/styles/panels/modal.css +++ b/packages/ui/src/styles/panels/modal.css @@ -46,6 +46,11 @@ color: var(--text-primary); } +.modal-item:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .modal-list-container[data-pointer-mode="pointer"] .modal-item:hover { background-color: var(--surface-hover); } diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 41468de7..c8a92642 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -6,6 +6,8 @@ --surface-muted: #f8fafc; --surface-code: #f1f5f9; --surface-hover: #e0e0e0; + --markdown-table-row-odd: transparent; + --markdown-table-row-even: #f1f5f9; /* Border tokens */ --border-base: #e0e0e0; @@ -180,6 +182,8 @@ --surface-muted: #212529; --surface-code: #1a1a1a; --surface-hover: #3a3a3a; + --markdown-table-row-odd: #0f1114; + --markdown-table-row-even: #181c22; /* Border tokens */ --border-base: #3a3a3a; @@ -347,6 +351,8 @@ --surface-muted: #212529; --surface-code: #1a1a1a; --surface-hover: #3a3a3a; + --markdown-table-row-odd: #0f1114; + --markdown-table-row-even: #181c22; /* Border tokens */ --border-base: #3a3a3a; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 230f9bd0..44aaa1fd 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -153,6 +153,19 @@ @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-start { overflow: hidden; diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 712d3d78..db964d27 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -20,6 +20,19 @@ export type { SDKMessage } +// Server streaming event: append-only delta updates. +// Emitted over SSE by newer OpenCode builds. +export interface MessagePartDeltaEvent { + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + export interface RenderCache { text: string html: string