Compare commits

...

22 Commits

Author SHA1 Message Date
Shantur Rathore
4f6c8523c0 Merge pull request #174 from NeuralNomadsAI/codenomad/issue-173
Docs: link server CLI docs and list flags/env vars
2026-02-15 15:30:33 +00:00
Shantur Rathore
8c24a7daf3 docs: reorganize server and dev release docs 2026-02-15 15:29:06 +00:00
Shantur Rathore
682937e945 docs(server): improve CLI flag/env var docs
Make server usage easier to discover from the root README, add local install/run instructions, and document additional CLI flags/env vars for UI and logging.
2026-02-15 15:21:09 +00:00
Shantur Rathore
35ff359c0f Merge pull request #170 from NeuralNomadsAI/codenomad/issue-153
Fix: hide keyboard shortcut hints in WebUI + add toggle
2026-02-15 09:24:30 +00:00
Shantur Rathore
c7195469bd fix(ui): add keyboard shortcut hints toggle
Hide shortcut hints in WebUI and allow toggling in native desktop apps.
2026-02-14 00:02:56 +00:00
Shantur Rathore
e9f281a69d Merge pull request #168 from NeuralNomadsAI/codenomad/issue-166
fix(ui): hide keyboard hints on phone layout
2026-02-13 10:15:53 +00:00
Shantur Rathore
36baac06b8 fix(ui): hide kbd hints on non-desktop 2026-02-13 10:02:15 +00:00
Shantur Rathore
3678214e69 fix(ui): hide keyboard hints on non-desktop 2026-02-13 09:54:46 +00:00
Shantur Rathore
338e3d9d38 fix(ui): hide keyboard hints on phone layout 2026-02-13 09:21:24 +00:00
Shantur Rathore
0c0f397db0 Merge pull request #164 from NeuralNomadsAI/codenomad/issue-159
fix(ui): keep prompt attachments in sync
2026-02-13 08:05:05 +00:00
Shantur Rathore
da70cc9944 fix(ui): keep prompt attachments in sync 2026-02-13 00:51:42 +00:00
Shantur Rathore
ba418a8518 chore(release): publish dev builds as codenomad-dev
Switch dev workflow to publish the server under @neuralnomads/codenomad-dev with dist-tag latest, avoiding @dev dist-tags. Add workflow input to override package name at publish time.
2026-02-13 00:39:14 +00:00
Shantur Rathore
ffe991bbe4 chore(release): simplify dev version format
Switch dev builds to use -dev-YYYYMMDD-sha8 suffix and update version parsing + dev detection accordingly.
2026-02-13 00:07:33 +00:00
Shantur Rathore
3047a1e602 fix(ci): avoid secrets context in step if
Remove secrets-based step conditionals in reusable npm publish workflow; decide token vs OIDC at runtime.
2026-02-12 23:58:18 +00:00
Shantur Rathore
e6c568988a fix(ci): declare NPM_TOKEN for reusable publish
Expose NPM_TOKEN as an optional workflow_call secret so step conditionals can reference secrets.NPM_TOKEN.
2026-02-12 23:55:58 +00:00
Shantur Rathore
45fab91e7f feat(release): add dev prereleases and update notices
Publish bleeding-edge builds from dev to GitHub prereleases and npm dist-tag 'dev'. Dev builds poll GitHub prereleases and surface update availability via /api/meta for UI notifications.
2026-02-12 23:53:16 +00:00
Shantur Rathore
d3484ec3af feat(config): migrate to YAML config and state.yaml 2026-02-12 23:53:16 +00:00
Shantur Rathore
cb0d601b09 Merge pull request #155 from seanburkes/fix/markdown-light-mode-visibility-fork
Fix markdown code block text visibility in light mode
2026-02-12 22:52:21 +00:00
Sean Burkes
9ea4f6b5ef fix: light/dark mode consistency with alternating table row colors 2026-02-12 15:21:07 -07:00
Shantur Rathore
bf9ee76de5 Merge pull request #162 from NeuralNomadsAI/codenomad/pr-161
Add new session icon to sessions sidebar header
2026-02-12 16:53:35 +00:00
Sean Burkes
67a530a83b Fix rendering for light mode table and diagnostic sections; add guards for shiki 2026-02-11 21:54:45 -07:00
Sean Burkes
612ec6af1b Fix markdown code block text visibility in light mode 2026-02-11 21:22:41 -07:00
60 changed files with 1225 additions and 217 deletions

View File

@@ -1,4 +1,4 @@
name: Dev CI name: Develop Pre-Release
on: on:
push: push:
@@ -7,12 +7,35 @@ on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read id-token: write
contents: write
concurrency:
group: dev-prerelease
cancel-in-progress: true
jobs: jobs:
dev-ci: prepare:
uses: ./.github/workflows/build-and-upload.yml runs-on: ubuntu-latest
outputs:
version_suffix: ${{ steps.vars.outputs.version_suffix }}
steps:
- name: Compute version suffix
id: vars
shell: bash
run: |
set -euo pipefail
SHA8="${GITHUB_SHA::8}"
DATE=$(date -u +%Y%m%d)
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
prerelease:
needs: prepare
uses: ./.github/workflows/reusable-release.yml
with: with:
upload: false version_suffix: ${{ needs.prepare.outputs.version_suffix }}
set_versions: false npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest
prerelease: true
release_ui: false
secrets: inherit secrets: inherit

View File

@@ -12,6 +12,11 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
package_name:
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
required: false
default: "@neuralnomads/codenomad"
type: string
workflow_call: workflow_call:
inputs: inputs:
version: version:
@@ -21,6 +26,13 @@ on:
required: false required: false
type: string type: string
default: dev default: dev
package_name:
required: false
type: string
default: "@neuralnomads/codenomad"
secrets:
NPM_TOKEN:
required: false
permissions: permissions:
contents: read contents: read
@@ -51,7 +63,7 @@ jobs:
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling) - name: Build server package (includes UI bundling)
run: npm run build --workspace @neuralnomads/codenomad run: npm run build --workspace packages/server
- name: Set publish metadata - name: Set publish metadata
shell: bash shell: bash
@@ -62,13 +74,31 @@ jobs:
fi fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV" echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV" echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
- name: Bump package version for publish - name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Set server package name for publish
shell: bash
run: |
set -euo pipefail
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
- name: Publish server package with provenance - name: Publish server package with provenance
env: env:
# Optional: when present, npm will use token auth.
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
NPM_CONFIG_REGISTRY: https://registry.npmjs.org NPM_CONFIG_REGISTRY: https://registry.npmjs.org
shell: bash
run: | run: |
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance set -euo pipefail
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
unset NODE_AUTH_TOKEN
else
echo "Using NPM_TOKEN authentication"
fi
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance

View File

@@ -14,4 +14,5 @@ jobs:
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
dist_tag: latest dist_tag: latest
npm_package_name: "@neuralnomads/codenomad"
secrets: inherit secrets: inherit

View File

@@ -13,6 +13,21 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
npm_package_name:
description: "npm package name to publish (defaults to server package name)"
required: false
default: ""
type: string
prerelease:
description: "Create GitHub prerelease"
required: false
default: false
type: boolean
release_ui:
description: "Publish remote UI + manifest"
required: false
default: true
type: boolean
permissions: permissions:
id-token: write id-token: write
@@ -53,11 +68,16 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }} TAG: ${{ steps.versions.outputs.tag }}
IS_PRERELEASE: ${{ inputs.prerelease }}
run: | run: |
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists" echo "Release $TAG already exists"
else else
gh release create "$TAG" --title "$TAG" --generate-notes if [ "${IS_PRERELEASE}" = "true" ]; then
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
fi fi
build-and-upload: build-and-upload:
@@ -71,6 +91,7 @@ jobs:
release-ui: release-ui:
needs: prepare-release needs: prepare-release
if: ${{ inputs.release_ui }}
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
@@ -84,4 +105,5 @@ jobs:
with: with:
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }}
secrets: inherit secrets: inherit

View File

@@ -44,13 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
For dev version Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash ```bash
npx @neuralnomads/codenomad@dev --launch npx @neuralnomads/codenomad --help
``` ```
This command starts the server and opens the web client in your default browser. ### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
## Highlights ## Highlights

19
package-lock.json generated
View File

@@ -11879,6 +11879,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"dev": true, "dev": true,
@@ -11974,7 +11989,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server" "@neuralnomads/codenomad": "file:../server",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -12017,6 +12033,7 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -5,6 +5,7 @@ import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
@@ -39,6 +40,36 @@ interface CliEntryResolution {
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
const resolved = resolveConfigPath(target)
if (isYamlPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
}
if (isJsonPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
}
// Treat as directory.
return {
configYamlPath: path.join(resolved, "config.yaml"),
legacyJsonPath: path.join(resolved, "config.json"),
}
}
function resolveConfigPath(configPath?: string): string { function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) { if (target.startsWith("~/")) {
@@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string {
function readListeningModeFromConfig(): ListeningMode { function readListeningModeFromConfig(): ListeningMode {
try { try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG) const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8") let parsed: any = null
const parsed = JSON.parse(content) if (existsSync(configYamlPath)) {
const content = readFileSync(configYamlPath, "utf-8")
parsed = parseYaml(content)
} else if (existsSync(legacyJsonPath)) {
const content = readFileSync(legacyJsonPath, "utf-8")
parsed = JSON.parse(content)
} else {
return "local"
}
const mode = parsed?.preferences?.listeningMode const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") { if (mode === "local" || mode === "all") {
return mode return mode

View File

@@ -36,7 +36,8 @@
}, },
"dependencies": { "dependencies": {
"@neuralnomads/codenomad": "file:../server", "@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui" "@codenomad/ui": "file:../ui",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

@@ -1 +1,4 @@
public/ public/
# Local developer config (may contain secrets)
config-*.json

View File

@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs: On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells) - `Local Connection URL : ...` (used by desktop shells)
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
codenomad --launch codenomad --launch
``` ```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags ### Common Flags
You can configure the server using flags or environment variables: You can configure the server using flags or environment variables:
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | | `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | | `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth | | `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh
npx @neuralnomads/codenomad-dev --launch
```
These environment variables control how CodeNomad checks for dev updates:
| Env Variable | Description |
|-------------|-------------|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
### HTTP vs HTTPS ### HTTP vs HTTPS

View File

@@ -34,6 +34,7 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -286,6 +286,8 @@ export interface ServerMeta {
serverVersion?: string serverVersion?: string
ui?: UiMeta ui?: UiMeta
support?: SupportMeta support?: SupportMeta
/** Optional update info (dev channel only). */
update?: LatestReleaseInfo | null
} }
export type BackgroundProcessStatus = "running" | "stopped" | "error" export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -0,0 +1,78 @@
import os from "os"
import path from "path"
export interface ConfigLocation {
/** Resolved absolute base directory containing all persisted server data. */
baseDir: string
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
configYamlPath: string
/** Canonical YAML state file path (always in baseDir). */
stateYamlPath: string
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
legacyJsonPath: string
/** Directory for per-instance persisted data (chat history etc.). */
instancesDir: string
}
function resolvePath(inputPath: string): string {
if (inputPath.startsWith("~/")) {
return path.join(os.homedir(), inputPath.slice(2))
}
return path.resolve(inputPath)
}
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
/**
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
*
* Supported inputs:
* - Directory: "~/.config/codenomad"
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
* - Legacy JSON file: "~/.config/codenomad/config.json"
*/
export function resolveConfigLocation(raw: string): ConfigLocation {
const trimmed = (raw ?? "").trim()
const fallback = "~/.config/codenomad/config.json"
const input = trimmed.length > 0 ? trimmed : fallback
const resolvedInput = resolvePath(input)
if (isYamlPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: resolvedInput,
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}
if (isJsonPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: resolvedInput,
instancesDir: path.join(baseDir, "instances"),
}
}
const baseDir = resolvedInput
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}

View File

@@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z.object({ const PreferencesSchema = z
.object({
showThinkingBlocks: z.boolean().default(false), showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true), showTimelineTools: z.boolean().default(true),
@@ -31,7 +32,9 @@ const PreferencesSchema = z.object({
osNotificationsAllowWhenVisible: z.boolean().default(false), osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true), notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true), notifyOnIdle: z.boolean().default(true),
}) })
// Preserve unknown preference keys so newer configs survive older binaries.
.passthrough()
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({
path: z.string(), path: z.string(),
@@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
label: z.string().optional(), label: z.string().optional(),
}) })
const ConfigFileSchema = z.object({ const ConfigFileSchema = z
preferences: PreferencesSchema.default({}), .object({
recentFolders: z.array(RecentFolderSchema).default([]), preferences: PreferencesSchema.default({}),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), recentFolders: z.array(RecentFolderSchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(), opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
}) theme: z.enum(["light", "dark", "system"]).optional(),
})
// Preserve unknown top-level keys so optional future features survive downgrades.
.passthrough()
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
const ConfigYamlSchema = z
.object({
preferences: PreferencesSchema.default({}),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
.passthrough()
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
const StateFileSchema = z
.object({
recentFolders: z.array(RecentFolderSchema).default([]),
})
.passthrough()
const DEFAULT_CONFIG = ConfigFileSchema.parse({}) const DEFAULT_CONFIG = ConfigFileSchema.parse({})
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
const DEFAULT_STATE = StateFileSchema.parse({})
export { export {
ModelPreferenceSchema, ModelPreferenceSchema,
@@ -62,7 +86,11 @@ export {
RecentFolderSchema, RecentFolderSchema,
OpenCodeBinarySchema, OpenCodeBinarySchema,
ConfigFileSchema, ConfigFileSchema,
ConfigYamlSchema,
StateFileSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
} }
export type ModelPreference = z.infer<typeof ModelPreferenceSchema> export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
@@ -72,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema> export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema> export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema> export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
export type StateFile = z.infer<typeof StateFileSchema>

View File

@@ -1,15 +1,27 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { Logger } from "../logger" import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema" import {
ConfigFile,
ConfigFileSchema,
ConfigYamlSchema,
DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
StateFile,
StateFileSchema,
} from "./schema"
import type { ConfigLocation } from "./location"
export class ConfigStore { export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG private cache: ConfigFile = DEFAULT_CONFIG
private state: StateFile = DEFAULT_STATE
private loaded = false private loaded = false
constructor( constructor(
private readonly configPath: string, private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined, private readonly eventBus: EventBus | undefined,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -20,19 +32,37 @@ export class ConfigStore {
} }
try { try {
const resolved = this.resolvePath(this.configPath) const configYamlPath = this.location.configYamlPath
if (fs.existsSync(resolved)) { const stateYamlPath = this.location.stateYamlPath
const content = fs.readFileSync(resolved, "utf-8") const legacyJsonPath = this.location.legacyJsonPath
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed) if (fs.existsSync(configYamlPath)) {
this.logger.debug({ resolved }, "Loaded existing config file") const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
const stateDoc = fs.existsSync(stateYamlPath)
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
: DEFAULT_STATE
this.state = stateDoc
this.cache = this.mergeDocs(configDoc, stateDoc)
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
} else if (fs.existsSync(legacyJsonPath)) {
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
this.state = migrated.state
this.cache = migrated.config
} else { } else {
this.cache = DEFAULT_CONFIG // Fresh install: write defaults.
this.logger.debug({ resolved }, "No config file found, using defaults") this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
this.persist()
this.logger.debug(
{ configYamlPath, stateYamlPath },
"No config files found, created default YAML config/state",
)
} }
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults") this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
this.cache = DEFAULT_CONFIG this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
} }
this.loaded = true this.loaded = true
@@ -48,9 +78,30 @@ export class ConfigStore {
this.commit(validated) this.commit(validated)
} }
/**
* Apply a merge-patch update to the current config.
* - Missing keys are preserved.
* - Object values are merged recursively.
* - Explicit `null` deletes keys.
* - Arrays are replaced.
*/
mergePatch(patch: unknown) {
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
throw new Error("Config patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current as any, patch as any)
const validated = ConfigFileSchema.parse(next)
this.commit(validated)
}
private commit(next: ConfigFile) { private commit(next: ConfigFile) {
this.cache = next this.cache = next
this.loaded = true this.loaded = true
this.state = {
...this.state,
recentFolders: next.recentFolders,
}
this.persist() this.persist()
const published = Boolean(this.eventBus) const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
@@ -60,19 +111,134 @@ export class ConfigStore {
private persist() { private persist() {
try { try {
const resolved = this.resolvePath(this.configPath) const configYamlPath = this.location.configYamlPath
fs.mkdirSync(path.dirname(resolved), { recursive: true }) const stateYamlPath = this.location.stateYamlPath
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file") fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
const stateYaml = stringifyYaml(this.state as any)
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to persist config") this.logger.warn({ err: error }, "Failed to persist config")
} }
} }
private resolvePath(filePath: string) { private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
if (filePath.startsWith("~/")) { const merged = {
return path.join(process.env.HOME ?? "", filePath.slice(2)) ...(configDoc as any),
// State wins for recent folders.
recentFolders: stateDoc.recentFolders ?? [],
} }
return path.resolve(filePath)
return ConfigFileSchema.parse(merged)
}
private readYamlFile<T>(
filePath: string,
fallback: T,
schema: { parse: (value: unknown) => T },
label: string,
): T {
try {
const content = fs.readFileSync(filePath, "utf-8")
const parsed = parseYaml(content)
return schema.parse(parsed ?? {})
} catch (error) {
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
return fallback
}
}
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
const configYamlPath = this.location.configYamlPath
const stateYamlPath = this.location.stateYamlPath
const content = fs.readFileSync(legacyJsonPath, "utf-8")
const parsed = JSON.parse(content)
const legacy = ConfigFileSchema.parse(parsed)
const state: StateFile = StateFileSchema.parse({
...DEFAULT_STATE,
recentFolders: legacy.recentFolders ?? [],
})
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
// Persist YAML docs first, then move legacy aside.
try {
fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
}
try {
const bakPath = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bakPath)
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
} catch (error) {
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
}
return { config: merged, state }
} }
} }
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
const clone: Record<string, unknown> = { ...(config as any) }
delete clone.recentFolders
return clone as any
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
function applyMergePatch(current: any, patch: any): any {
// RFC 7396-ish merge patch with explicit null deletes.
if (!isPlainObject(patch)) {
return patch
}
const base = isPlainObject(current) ? { ...current } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
if (isPlainObject(value) && isPlainObject(base[key])) {
base[key] = applyMergePatch(base[key], value)
continue
}
// Arrays and scalars replace.
base[key] = value
}
return base
}
function pickBackupPath(legacyJsonPath: string): string {
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
const preferred = `${base}.json.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${base}.json.bak.${Date.now()}`
}

View File

@@ -9,6 +9,7 @@ import { createRequire } from "module"
import { createHttpServer } from "./server/http-server" import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager" import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store" import { ConfigStore } from "./config/store"
import { resolveConfigLocation } from "./config/location"
import { BinaryRegistry } from "./config/binaries" import { BinaryRegistry } from "./config/binaries"
import { FileSystemBrowser } from "./filesystem/browser" import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
@@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses" import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string {
return trimmed return trimmed
} }
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean { function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag) return argv.includes(flag)
} }
@@ -245,7 +240,8 @@ async function main() {
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath)) const configLocation = resolveConfigLocation(options.configPath)
const configDir = configLocation.baseDir
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
@@ -266,7 +262,7 @@ async function main() {
const authManager = new AuthManager( const authManager = new AuthManager(
{ {
configPath: options.configPath, configPath: configLocation.configYamlPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
generateToken: options.generateToken, generateToken: options.generateToken,
@@ -295,7 +291,16 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger) const configStore = new ConfigStore(configLocation, eventBus, configLogger)
// Eagerly load config at boot so migrations run immediately
// (instead of waiting for the first /api/config request).
try {
configStore.get()
} catch (error) {
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
}
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir, rootDir: options.rootDir,
@@ -307,7 +312,7 @@ async function main() {
nodeExtraCaCertsPath, nodeExtraCaCertsPath,
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore(configLocation.instancesDir)
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -344,6 +349,21 @@ async function main() {
minServerVersion: uiResolution.minServerVersion, minServerVersion: uiResolution.minServerVersion,
} }
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
const devReleaseMonitor = enableDevUpdateChecks
? startDevReleaseMonitor({
currentVersion: packageJson.version,
repo: githubRepo,
logger: logger.child({ component: "updates" }),
onUpdate: (release) => {
serverMeta.update = release
},
})
: null
if (uiResolution.uiDevServerUrl && options.https) { if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
} }
@@ -503,6 +523,8 @@ async function main() {
// no-op: remote UI manifest replaces GitHub release monitor // no-op: remote UI manifest replaces GitHub release monitor
devReleaseMonitor?.stop()
logger.info("Exiting process") logger.info("Exiting process")
process.exit(0) process.exit(0)
} }

View File

@@ -0,0 +1,118 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
interface DevReleaseMonitorOptions {
/** Current running server version (from package.json). */
currentVersion: string
/** GitHub repo in the form "owner/name". */
repo: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
pollIntervalMs?: number
}
interface GithubReleaseListItem {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
draft?: boolean
}
export interface DevReleaseMonitor {
stop(): void
}
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
let stopped = false
let timer: ReturnType<typeof setInterval> | null = null
const pollIntervalMs =
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
? (options.pollIntervalMs as number)
: DEFAULT_POLL_INTERVAL_MS
const refresh = async () => {
if (stopped) return
try {
const release = await fetchLatestPrerelease({
repo: options.repo,
currentVersion: options.currentVersion,
})
options.onUpdate(release)
} catch (error) {
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
}
}
void refresh()
timer = setInterval(() => void refresh(), pollIntervalMs)
return {
stop() {
stopped = true
if (timer) {
clearInterval(timer)
timer = null
}
},
}
}
async function fetchLatestPrerelease(args: {
repo: string
currentVersion: string
}): Promise<LatestReleaseInfo | null> {
const normalizedRepo = args.repo.trim()
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
throw new Error(`Invalid GitHub repo: ${args.repo}`)
}
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
const response = await fetch(apiUrl, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`GitHub releases API responded with ${response.status}`)
}
const list = (await response.json()) as GithubReleaseListItem[]
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
if (!latest) {
return null
}
const tag = latest.tag_name || latest.name
if (!tag) {
return null
}
const normalizedVersion = stripTagPrefix(tag)
if (!normalizedVersion) {
return null
}
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
return null
}
return {
version: normalizedVersion,
tag,
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
channel: "dev",
publishedAt: latest.published_at ?? latest.created_at,
notes: latest.body,
}
}

View File

@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
} }
} }
export function compareVersionStrings(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
return compareVersions(left, right)
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> { async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, { const response = await fetch(RELEASES_API_URL, {
headers: { headers: {
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
} }
} }
function stripTagPrefix(tag: string | undefined): string | null { export function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null if (!tag) return null
const trimmed = tag.trim() const trimmed = tag.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
function parseVersion(value: string): NormalizedVersion { function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0" const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2) const dashIndex = normalized.indexOf("-")
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => { const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10) const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0 return Number.isFinite(parsed) ? parsed : 0

View File

@@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { ConfigStore } from "../../config/store" import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries" import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps { interface RouteDeps {
configStore: ConfigStore configStore: ConfigStore
@@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get()) app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => { app.put("/api/config/app", async (request, reply) => {
const body = ConfigFileSchema.parse(request.body ?? {}) // Backwards compatible: treat PUT as a merge-patch update.
deps.configStore.replace(body) try {
return deps.configStore.get() deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.patch("/api/config/app", async (request, reply) => {
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
}) })
app.get("/api/config/binaries", async () => { app.get("/api/config/binaries", async () => {

View File

@@ -636,6 +636,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -3894,6 +3895,19 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.12.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -5015,6 +5029,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"

View File

@@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
tauri = { version = "2.5.2", features = [ "devtools"] } tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
regex = "1" regex = "1"
once_cell = "1" once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"

View File

@@ -145,12 +145,33 @@ struct AppConfig {
preferences: Option<PreferencesConfig>, preferences: Option<PreferencesConfig>,
} }
fn resolve_config_path() -> PathBuf { fn resolve_config_locations() -> (PathBuf, PathBuf) {
let raw = env::var("CLI_CONFIG") let raw = env::var("CLI_CONFIG")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
let expanded = expand_home(&raw);
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (expanded, base.join("config.json"));
}
if lower.ends_with(".json") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (base.join("config.yaml"), expanded);
}
// Treat as directory.
(expanded.join("config.yaml"), expanded.join("config.json"))
} }
fn expand_home(path: &str) -> PathBuf { fn expand_home(path: &str) -> PathBuf {
@@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf {
} }
fn resolve_listening_mode() -> String { fn resolve_listening_mode() -> String {
let path = resolve_config_path(); let (yaml_path, json_path) = resolve_config_locations();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(content) = fs::read_to_string(&yaml_path) {
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
// Legacy fallback.
if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) { if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config if let Some(mode) = config
.preferences .preferences
@@ -260,7 +300,14 @@ impl CliProcessManager {
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) { if let Err(err) = Self::spawn_cli(
app.clone(),
status_arc.clone(),
child_arc,
ready_flag,
token_arc,
dev,
) {
log_line(&format!("cli spawn failed: {err}")); log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock(); let mut locked = status_arc.lock();
locked.state = CliState::Error; locked.state = CliState::Error;
@@ -369,7 +416,9 @@ impl CliProcessManager {
if !supports_user_shell() { if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() { if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed.")); return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
} }
} }
@@ -420,7 +469,6 @@ impl CliProcessManager {
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
.lock() .lock()
.as_mut() .as_mut()
@@ -433,10 +481,24 @@ impl CliProcessManager {
.map(BufReader::new); .map(BufReader::new);
if let Some(reader) = stdout { if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone); Self::process_stream(
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone); Self::process_stream(
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
}); });
@@ -509,8 +571,14 @@ impl CliProcessManager {
if locked.error.is_none() { if locked.error.is_none() {
locked.error = err_msg.clone(); locked.error = err_msg.clone();
} }
log_line(&format!("cli process exited before ready: {:?}", locked.error)); log_line(&format!(
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()})); "cli process exited before ready: {:?}",
locked.error
));
let _ = app_clone.emit(
"cli:error",
json!({"message": locked.error.clone().unwrap_or_default()}),
);
} else { } else {
locked.state = CliState::Stopped; locked.state = CliState::Stopped;
log_line("cli process stopped cleanly"); log_line("cli process stopped cleanly");
@@ -574,13 +642,25 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}")); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{port}"),
);
continue; continue;
} }
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) { if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port)); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{}", port),
);
continue; continue;
} }
} }
@@ -719,7 +799,12 @@ impl CliEntry {
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()]; let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--generate-token".to_string(),
];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: plain HTTP + Vite dev server proxy.
@@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
std::env::current_dir() std::env::current_dir()
.ok() .ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")), .map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe() std::env::current_exe().ok().and_then(|ex| {
.ok() ex.parent()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))), .map(|p| p.join("../node_modules/tsx/dist/cli.js"))
}),
]; ];
first_existing(candidates) first_existing(candidates)
@@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root(); let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![ let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")), base.as_ref()
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")), base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")), base.as_ref().map(|p| p.join("server/dist/index.js")),
]; ];
@@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
candidates.push(Some(resources.join("resources/server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js"))); candidates.push(Some(
resources.join("resources/server/dist/server/index.js"),
));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots { for root in linux_resource_roots {
@@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
first_existing(candidates) first_existing(candidates)
} }
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> { fn build_shell_command_string(
entry: &CliEntry,
cli_args: &[String],
) -> anyhow::Result<ShellCommand> {
let shell = default_shell(); let shell = default_shell();
let mut quoted: Vec<String> = Vec::new(); let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary)); quoted.push(shell_escape(&entry.node_binary));
@@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String {
"''".to_string() "''".to_string()
} else if !input } else if !input
.chars() .chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' )) .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
{ {
input.to_string() input.to_string()
} else { } else {

View File

@@ -60,6 +60,7 @@ const App: Component = () => {
preferences, preferences,
recordWorkspaceLaunch, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
@@ -80,6 +81,13 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
const updateInstanceTabBarHeight = () => { const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance") const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -293,6 +301,7 @@ const App: Component = () => {
preferences, preferences,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,

View File

@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const groupedCommandList = () => processedCommands().groups const groupedCommandList = () => processedCommands().groups
const orderedCommands = () => processedCommands().ordered const orderedCommands = () => processedCommands().ordered
const isCommandDisabled = (command: Command) => {
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
}
const selectedIndex = createMemo(() => { const selectedIndex = createMemo(() => {
const ordered = orderedCommands() const ordered = orderedCommands()
if (ordered.length === 0) return -1 if (ordered.length === 0) return -1
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
} }
return return
} }
const currentId = selectedCommandId() const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) { if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
setSelectedCommandId(ordered[0].id) const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
} }
}) })
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
if (index < 0 || index >= ordered.length) return if (index < 0 || index >= ordered.length) return
const command = ordered[index] const command = ordered[index]
if (!command) return if (!command) return
if (isCommandDisabled(command)) return
props.onExecute(command) props.onExecute(command)
props.onClose() props.onClose()
} }
} }
function handleCommandClick(command: Command) { function handleCommandClick(command: Command) {
if (isCommandDisabled(command)) return
props.onExecute(command) props.onExecute(command)
props.onClose() props.onClose()
} }
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<For each={group.commands}> <For each={group.commands}>
{(command, localIndex) => { {(command, localIndex) => {
const commandIndex = group.startIndex + localIndex() const commandIndex = group.startIndex + localIndex()
const disabled = isCommandDisabled(command)
return ( return (
<button <button
type="button" type="button"
data-command-index={commandIndex} data-command-index={commandIndex}
onClick={() => handleCommandClick(command)} onClick={() => handleCommandClick(command)}
disabled={disabled}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`} class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => { onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return if (event.movementX === 0 && event.movementY === 0) return

View File

@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</Show> </Show>
</div> </div>
<div class="panel-footer"> <div class="panel-footer keyboard-hints">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>

View File

@@ -548,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")} : t("folderSelection.browse.button")}
</span> </span>
</div> </div>
<Kbd shortcut="cmd+n" class="ml-2" /> <Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button> </button>
</div> </div>
@@ -573,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
<div class="panel panel-footer shrink-0 hidden sm:block"> <div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<Show when={folders().length > 0}> <Show when={folders().length > 0}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -591,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" /> <Kbd shortcut="cmd+n" class="kbd-hint" />
<span>{t("folderSelection.hints.browse")}</span> <span>{t("folderSelection.hints.browse")}</span>
</div> </div>
</div> </div>

View File

@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
interface HintRowProps { interface HintRowProps {
children: JSX.Element children: JSX.Element
class?: string class?: string
ariaHidden?: boolean
} }
const HintRow: Component<HintRowProps> = (props) => { const HintRow: Component<HintRowProps> = (props) => {
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span> return (
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
{props.children}
</span>
)
} }
export default HintRow export default HintRow

View File

@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
)} )}
<span>{t("instanceWelcome.new.createButton")}</span> <span>{t("instanceWelcome.new.createButton")}</span>
</div> </div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2" /> <Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
</button> </button>
</div> </div>
</div> </div>
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div> </div>
</Show> </Show>
<div class="panel-footer hidden sm:block"> <div class="panel-footer hidden sm:block keyboard-hints">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">

View File

@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
{t("instanceShell.commandPalette.button")} {t("instanceShell.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div> </div>
<div class="session-toolbar-right flex-1 flex items-center gap-3"> <div class="session-toolbar-right flex-1 flex items-center gap-3">
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>

View File

@@ -1,7 +1,7 @@
import { Show, type Accessor, type Component } from "solid-js" import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state" import type { SessionThread } from "../../../stores/session-state"
import type { Session } from "../../../types/session" import type { Session } from "../../../types/session"
import type { KeyboardShortcut } from "../../../lib/keyboard-registry" import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
import type { DrawerViewState } from "./types" import type { DrawerViewState } from "./types"
import { PlusSquare, Search } from "lucide-solid" import { PlusSquare, Search } from "lucide-solid"
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import SessionList from "../../session-list" import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint" import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector" import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector" import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector" import ModelSelector from "../../model-selector"
@@ -166,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} /> <ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true"> <KeyboardHint
<Kbd shortcut="cmd+shift+a" /> class="session-sidebar-selector-hints"
<Kbd shortcut="cmd+shift+m" /> ariaHidden={true}
<Kbd shortcut="cmd+shift+t" /> shortcuts={[
</div> keyboardRegistry.get("open-agent-selector"),
keyboardRegistry.get("focus-model"),
keyboardRegistry.get("focus-variant"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
separator=" "
showDescription={false}
/>
</div> </div>
</> </>
)} )}

View File

@@ -1,4 +1,5 @@
import { Component, JSX, For } from "solid-js" import { Component, JSX, For } from "solid-js"
import useMediaQuery from "@suid/material/useMediaQuery"
import { isMac } from "../lib/keyboard-utils" import { isMac } from "../lib/keyboard-utils"
interface KbdProps { interface KbdProps {
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
} }
const Kbd: Component<KbdProps> = (props) => { const Kbd: Component<KbdProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)")
if (!desktopQuery()) return null
const parts = () => { const parts = () => {
if (props.children) return [{ text: props.children, isModifier: false }] if (props.children) return [{ text: props.children, isModifier: false }]
if (!props.shortcut) return [] if (!props.shortcut) return []

View File

@@ -1,14 +1,20 @@
import { Component, For } from "solid-js" import { Component, For } from "solid-js"
import { formatShortcut, isMac } from "../lib/keyboard-utils" import useMediaQuery from "@suid/material/useMediaQuery"
import type { KeyboardShortcut } from "../lib/keyboard-registry" import type { KeyboardShortcut } from "../lib/keyboard-registry"
import Kbd from "./kbd" import Kbd from "./kbd"
import HintRow from "./hint-row" import HintRow from "./hint-row"
const KeyboardHint: Component<{ const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[] shortcuts: KeyboardShortcut[]
separator?: string separator?: string | null
showDescription?: boolean showDescription?: boolean
class?: string
ariaHidden?: boolean
}> = (props) => { }> = (props) => {
// Centralize layout gating here so call sites don't need to.
// We only show keyboard hint UI on desktop layouts.
const desktopQuery = useMediaQuery("(min-width: 1280px)")
function buildShortcutString(shortcut: KeyboardShortcut): string { function buildShortcutString(shortcut: KeyboardShortcut): string {
const parts: string[] = [] const parts: string[] = []
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
return parts.join("+") return parts.join("+")
} }
if (!desktopQuery()) return null
return ( return (
<HintRow> <HintRow class={props.class} ariaHidden={props.ariaHidden}>
<For each={props.shortcuts}> <For each={props.shortcuts}>
{(shortcut, i) => ( {(shortcut, i) => (
<> <>
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>} {i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>} {props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
<Kbd shortcut={buildShortcutString(shortcut)} /> <Kbd shortcut={buildShortcutString(shortcut)} />
</> </>

View File

@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
{t("messageListHeader.commandPalette.button")} {t("messageListHeader.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" class="kbd-hint" />
</span> </span>
</div> </div>
</div> </div>

View File

@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
<ul> <ul>
<li> <li>
<span>{t("messageSection.empty.tips.commandPalette")}</span> <span>{t("messageSection.empty.tips.commandPalette")}</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" /> <Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
</li> </li>
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li> <li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li> <li>

View File

@@ -1,8 +1,8 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
handleDrop, handleDrop,
syncAttachmentCounters, syncAttachmentCounters,
handleExpandTextAttachment, handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({ } = usePromptAttachments({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
if (!attachment) return if (!attachment) return
handleExpandTextAttachment(attachment) handleExpandTextAttachment(attachment)
}, },
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => { setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef const textarea = textareaRef
if (textarea) { if (textarea) {
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null) setAtPosition(null)
setSearchQuery("") setSearchQuery("")
const instanceId = props.instanceId syncAttachmentCounters(prompt())
const sessionId = props.sessionId
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
syncAttachmentCounters(prompt(), currentAttachments)
}, },
{ defer: true }, { defer: true },
), ),
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
// Ignore attachments for slash commands, but keep them for next prompt. // Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) { if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId) clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("", []) syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
syncAttachmentCounters("", currentAttachments) syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -479,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</div> </div>
<Show when={shouldShowOverlay()}> <Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}> <div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show <Show
when={props.escapeInDebounce} when={props.escapeInDebounce}
fallback={ fallback={

View File

@@ -1,5 +1,3 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) { export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]` return `[pasted #${value}]`
} }
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
} }
export function createPastedPlaceholderRegex() { export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
} }
export function createImagePlaceholderRegex() { export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
} }
export function createMentionRegex() { export function createMentionRegex() {
return /@(\S+)/g return /@(\S+)/g
} }
export const pastedDisplayCounterRegex = /pasted #(\d+)/ export const pastedDisplayCounterRegex = /pasted #(\d+)/i
export const imageDisplayCounterRegex = /Image #(\d+)/ export const imageDisplayCounterRegex = /Image #(\d+)/i
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/ export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
export function parseCounter(value: string) { export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed return Number.isNaN(parsed) ? null : parsed
} }
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { export function findHighestAttachmentCounters(currentPrompt: string) {
let highestPaste = 0 let highestPaste = 0
let highestImage = 0 let highestImage = 0
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
} }
} }
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1]) const parsed = parseCounter(match[1])
if (parsed !== null) { if (parsed !== null) {

View File

@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi { export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void focus(): void
} }

View File

@@ -1,4 +1,4 @@
import { createSignal, type Accessor } from "solid-js" import { createEffect, createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment" import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
@@ -7,6 +7,7 @@ import {
findHighestAttachmentCounters, findHighestAttachmentCounters,
formatImagePlaceholder, formatImagePlaceholder,
formatPastedPlaceholder, formatPastedPlaceholder,
imageDisplayCounterRegex,
pastedDisplayCounterRegex, pastedDisplayCounterRegex,
} from "./attachmentPlaceholders" } from "./attachmentPlaceholders"
@@ -23,7 +24,7 @@ type PromptAttachments = {
attachments: Accessor<Attachment[]> attachments: Accessor<Attachment[]>
pasteCount: Accessor<number> pasteCount: Accessor<number>
imageCount: Accessor<number> imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void syncAttachmentCounters: (promptText: string) => void
handlePaste: (e: ClipboardEvent) => Promise<void> handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean> isDragging: Accessor<boolean>
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const [pasteCount, setPasteCount] = createSignal(0) const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { function syncAttachmentCounters(currentPrompt: string) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
setPasteCount(highestPaste) setPasteCount(highestPaste)
setImageCount(highestImage) setImageCount(highestImage)
} }
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
const next = currentPrompt.replace(tokenRegex, "")
if (next === currentPrompt) return currentPrompt
return next
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.trim()
}
const createLooseImagePlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
// Keep placeholder-backed attachments in sync with prompt text.
// If the placeholder token disappears from the prompt, the attachment should disappear too.
createEffect(() => {
const currentPrompt = options.prompt()
const currentAttachments = attachments()
const toRemove: string[] = []
for (const attachment of currentAttachments) {
if (attachment.source.type === "text") {
const match = attachment.display.match(pastedDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
continue
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const match =
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
}
}
for (const attachmentId of toRemove) {
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
}
})
function handleRemoveAttachment(attachmentId: string) { function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments() const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId) const attachment = currentAttachments.find((a) => a.id === attachmentId)
// Always remove from store.
removeAttachment(options.instanceId(), options.sessionId(), attachmentId) removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (attachment) { if (!attachment) return
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
if (attachment.source.type === "file") { const currentPrompt = options.prompt()
if (attachment.mediaType.startsWith("image/")) { let nextPrompt = currentPrompt
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (imageMatch) { if (attachment.source.type === "file") {
const placeholder = formatImagePlaceholder(imageMatch[1]) if (attachment.mediaType.startsWith("image/")) {
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() const imageMatch =
} attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
} else { if (imageMatch) {
const filename = attachment.filename nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
} }
} else if (attachment.source.type === "agent") { } else {
const agentName = attachment.filename // For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() const candidates = [attachment.source.path, attachment.filename]
} else if (attachment.source.type === "text") { for (const candidate of candidates) {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) if (!candidate) continue
if (placeholderMatch) { const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
const placeholder = formatPastedPlaceholder(placeholderMatch[1]) nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
} }
} }
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
}
}
options.setPrompt(newPrompt) if (nextPrompt !== currentPrompt) {
options.setPrompt(nextPrompt)
} }
} }
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const blob = item.getAsFile() const blob = item.getAsFile()
if (!blob) continue if (!blob) continue
const count = imageCount() + 1 const { highestImage } = findHighestAttachmentCounters(options.prompt())
const count = highestImage + 1
setImageCount(count) setImageCount(count)
const placeholder = formatImagePlaceholder(count)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
}
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1] const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png` const filename = `image-${count}.png`
const attachment = createFileAttachment( const attachment = createFileAttachment(
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
options.instanceFolder(), options.instanceFolder(),
) )
attachment.url = `data:image/png;base64,${base64Data}` attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = display attachment.display = placeholder
addAttachment(options.instanceId(), options.sessionId(), attachment) addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
} }
reader.readAsDataURL(blob) reader.readAsDataURL(blob)
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
if (isLongPaste) { if (isLongPaste) {
e.preventDefault() e.preventDefault()
const count = pasteCount() + 1 const { highestPaste } = findHighestAttachmentCounters(options.prompt())
const count = highestPaste + 1
setPasteCount(count) setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const filename = `paste-${count}.txt` const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename) const attachment = createTextAttachment(pastedText, display, filename)
addAttachment(options.instanceId(), options.sessionId(), attachment) const placeholder = formatPastedPlaceholder(count)
const textarea = options.getTextarea() const textarea = options.getTextarea()
if (textarea) { if (textarea) {
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const currentText = options.prompt() const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText) options.setPrompt(newText)
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus() textarea.focus()
}, 0) }, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
} }
addAttachment(options.instanceId(), options.sessionId(), attachment)
} }
} }

View File

@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</span> </span>
</Show> </Show>
</div> </div>
<kbd class="kbd ml-2"> <kbd class="kbd ml-2 kbd-hint">
Cmd+Enter Cmd+Enter
</kbd> </kbd>
</button> </button>

View File

@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/> />
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<PromptAttachmentsBar <PromptAttachmentsBar
attachments={attachments()} attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)} onRemoveAttachment={(attachmentId) => {
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} if (promptInputApi) {
/> promptInputApi.removeAttachment(attachmentId)
</Show> return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}

View File

@@ -18,6 +18,7 @@ export interface Command {
description: Resolvable<string> description: Resolvable<string>
keywords?: Resolvable<string[]> keywords?: Resolvable<string[]>
shortcut?: KeyboardShortcut shortcut?: KeyboardShortcut
disabled?: Resolvable<boolean>
action: () => void | Promise<void> action: () => void | Promise<void>
category?: Resolvable<string> category?: Resolvable<string>
} }

View File

@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api" import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events" import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n" import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions") const log = getLogger("actions")
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions { export interface UseCommandsOptions {
preferences: Accessor<Preferences> preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void toggleAutoCleanupBlankSessions: () => void
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
action: options.toggleShowTimelineTools, action: options.toggleShowTimelineTools,
}) })
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({ commandRegistry.register({
id: "thinking-default-visibility", id: "thinking-default-visibility",
label: () => { label: () => {

View File

@@ -30,6 +30,10 @@ export const appMessages = {
"releases.uiUpdated.title": "UI updated", "releases.uiUpdated.title": "UI updated",
"releases.uiUpdated.message": "UI is now updated to {version}.", "releases.uiUpdated.message": "UI is now updated to {version}.",
"releases.devUpdateAvailable.title": "Dev build available",
"releases.devUpdateAvailable.message": "A new dev build is available: {version}.",
"releases.devUpdateAvailable.action": "View release",
"theme.mode.system": "System", "theme.mode.system": "System",
"theme.mode.light": "Light", "theme.mode.light": "Light",
"theme.mode.dark": "Dark", "theme.mode.dark": "Dark",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline", "commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle", "commands.timelineToolCalls.keywords": "timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
"commands.common.expanded": "Expanded", "commands.common.expanded": "Expanded",
"commands.common.collapsed": "Collapsed", "commands.common.collapsed": "Collapsed",
"commands.common.visible": "Visible", "commands.common.visible": "Visible",

View File

@@ -26,4 +26,11 @@ export const appMessages = {
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.", "releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.", "releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
"releases.upgradeRequired.action.getUpdate": "Obtener actualización", "releases.upgradeRequired.action.getUpdate": "Obtener actualización",
"releases.uiUpdated.title": "UI actualizada",
"releases.uiUpdated.message": "La UI ahora está actualizada a {version}.",
"releases.devUpdateAvailable.title": "Compilación dev disponible",
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
"releases.devUpdateAvailable.action": "Ver release",
} as const } as const

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes", "commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar", "commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
"commands.common.expanded": "Expandido", "commands.common.expanded": "Expandido",
"commands.common.collapsed": "Colapsado", "commands.common.collapsed": "Colapsado",
"commands.common.visible": "Visible", "commands.common.visible": "Visible",

View File

@@ -26,4 +26,11 @@ export const appMessages = {
"releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.", "releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.",
"releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.", "releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour", "releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
"releases.uiUpdated.title": "UI mise à jour",
"releases.uiUpdated.message": "L'UI est maintenant mise à jour vers {version}.",
"releases.devUpdateAvailable.title": "Build dev disponible",
"releases.devUpdateAvailable.message": "Un nouveau build dev est disponible : {version}.",
"releases.devUpdateAvailable.action": "Voir la release",
} as const } as const

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages", "commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
"commands.timelineToolCalls.keywords": "timeline, outil, basculer", "commands.timelineToolCalls.keywords": "timeline, outil, basculer",
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
"commands.common.expanded": "Développé", "commands.common.expanded": "Développé",
"commands.common.collapsed": "Réduit", "commands.common.collapsed": "Réduit",
"commands.common.visible": "Visible", "commands.common.visible": "Visible",

View File

@@ -26,4 +26,11 @@ export const appMessages = {
"releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。", "releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。",
"releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。", "releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。",
"releases.upgradeRequired.action.getUpdate": "更新を取得", "releases.upgradeRequired.action.getUpdate": "更新を取得",
"releases.uiUpdated.title": "UI を更新しました",
"releases.uiUpdated.message": "UI が {version} に更新されました。",
"releases.devUpdateAvailable.title": "開発版が利用可能",
"releases.devUpdateAvailable.message": "新しい開発版が利用可能です: {version}。",
"releases.devUpdateAvailable.action": "リリースを見る",
} as const } as const

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え", "commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle", "commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
"commands.common.expanded": "展開", "commands.common.expanded": "展開",
"commands.common.collapsed": "折りたたみ", "commands.common.collapsed": "折りたたみ",
"commands.common.visible": "表示", "commands.common.visible": "表示",

View File

@@ -26,4 +26,11 @@ export const appMessages = {
"releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.", "releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.",
"releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.", "releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.",
"releases.upgradeRequired.action.getUpdate": "Получить обновление", "releases.upgradeRequired.action.getUpdate": "Получить обновление",
"releases.uiUpdated.title": "UI обновлён",
"releases.uiUpdated.message": "UI теперь обновлён до {version}.",
"releases.devUpdateAvailable.title": "Доступна dev-сборка",
"releases.devUpdateAvailable.message": "Доступна новая dev-сборка: {version}.",
"releases.devUpdateAvailable.action": "Открыть релиз",
} as const } as const

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений", "commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить", "commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
"commands.common.expanded": "Развернуто", "commands.common.expanded": "Развернуто",
"commands.common.collapsed": "Свернуто", "commands.common.collapsed": "Свернуто",
"commands.common.visible": "Видимо", "commands.common.visible": "Видимо",

View File

@@ -26,4 +26,11 @@ export const appMessages = {
"releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。", "releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。",
"releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。", "releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。",
"releases.upgradeRequired.action.getUpdate": "获取更新", "releases.upgradeRequired.action.getUpdate": "获取更新",
"releases.uiUpdated.title": "UI 已更新",
"releases.uiUpdated.message": "UI 已更新到 {version}。",
"releases.devUpdateAvailable.title": "可用的开发版",
"releases.devUpdateAvailable.message": "有新的开发版可用:{version}。",
"releases.devUpdateAvailable.action": "查看发布",
} as const } as const

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目", "commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换", "commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
"commands.common.expanded": "展开", "commands.common.expanded": "展开",
"commands.common.collapsed": "折叠", "commands.common.collapsed": "折叠",
"commands.common.visible": "可见", "commands.common.visible": "可见",

View File

@@ -34,6 +34,7 @@ export type ListeningMode = "local" | "all"
export interface Preferences { export interface Preferences {
showThinkingBlocks: boolean showThinkingBlocks: boolean
showKeyboardShortcutHints: boolean
thinkingBlocksExpansion: ExpansionPreference thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean showTimelineTools: boolean
promptSubmitOnEnter: boolean promptSubmitOnEnter: boolean
@@ -78,6 +79,7 @@ const MAX_FAVORITE_MODELS = 50
const defaultPreferences: Preferences = { const defaultPreferences: Preferences = {
showThinkingBlocks: false, showThinkingBlocks: false,
showKeyboardShortcutHints: true,
thinkingBlocksExpansion: "expanded", thinkingBlocksExpansion: "expanded",
showTimelineTools: true, showTimelineTools: true,
promptSubmitOnEnter: false, promptSubmitOnEnter: false,
@@ -131,6 +133,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
return { return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
@@ -393,6 +396,10 @@ function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
} }
function toggleKeyboardShortcutHints(): void {
updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints })
}
function toggleShowTimelineTools(): void { function toggleShowTimelineTools(): void {
updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
} }
@@ -511,6 +518,7 @@ interface ConfigContextValue {
setThemePreference: typeof setThemePreference setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints
toggleShowTimelineTools: typeof toggleShowTimelineTools toggleShowTimelineTools: typeof toggleShowTimelineTools
toggleUsageMetrics: typeof toggleUsageMetrics toggleUsageMetrics: typeof toggleUsageMetrics
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
@@ -548,6 +556,7 @@ const configContextValue: ConfigContextValue = {
setThemePreference, setThemePreference,
updateConfig, updateConfig,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
@@ -608,6 +617,7 @@ export {
updateConfig, updateConfig,
updatePreferences, updatePreferences,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,

View File

@@ -11,12 +11,15 @@ const log = getLogger("actions")
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null) const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion" const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion"
const DEV_RELEASE_STORAGE_KEY = "codenomad:lastSeenDevRelease"
const META_REFRESH_INTERVAL_MS = 10 * 60 * 1000
let initialized = false let initialized = false
let visibilityEffectInitialized = false let visibilityEffectInitialized = false
let activeToast: ToastHandle | null = null let activeToast: ToastHandle | null = null
let activeToastKey: string | null = null let activeToastKey: string | null = null
let uiUpdateToasted = false let uiUpdateToasted = false
let metaRefreshInterval: ReturnType<typeof setInterval> | null = null
function dismissActiveToast() { function dismissActiveToast() {
if (activeToast) { if (activeToast) {
@@ -80,6 +83,8 @@ async function refreshFromMeta() {
const meta = await getServerMeta(true) const meta = await getServerMeta(true)
setSupportInfo(meta.support ?? null) setSupportInfo(meta.support ?? null)
maybeNotifyUiUpdated(meta) maybeNotifyUiUpdated(meta)
maybeNotifyDevReleaseAvailable(meta)
ensureMetaRefresh(meta)
} catch (error) { } catch (error) {
log.warn("Unable to load server metadata for support info", error) log.warn("Unable to load server metadata for support info", error)
} }
@@ -115,6 +120,46 @@ function maybeNotifyUiUpdated(meta: ServerMeta) {
}) })
} }
function maybeNotifyDevReleaseAvailable(meta: ServerMeta) {
const update = meta.update
if (!update || !update.version || !update.url) return
const lastSeen = safeReadLocalStorage(DEV_RELEASE_STORAGE_KEY)
if (lastSeen === update.version) {
return
}
safeWriteLocalStorage(DEV_RELEASE_STORAGE_KEY, update.version)
showToastNotification({
title: tGlobal("releases.devUpdateAvailable.title"),
message: tGlobal("releases.devUpdateAvailable.message", { version: update.version }),
variant: "info",
duration: 12000,
position: "bottom-right",
action: {
label: tGlobal("releases.devUpdateAvailable.action"),
href: update.url,
},
})
}
function ensureMetaRefresh(meta: ServerMeta) {
if (metaRefreshInterval) return
const version = meta.serverVersion?.trim() ?? ""
const looksLikeDev = version.includes("-dev.") || version.includes("-dev-")
const hasDevUpdateChannel = Boolean(meta.update)
if (!looksLikeDev && !hasDevUpdateChannel) {
return
}
metaRefreshInterval = setInterval(() => {
void refreshFromMeta()
}, META_REFRESH_INTERVAL_MS)
}
function safeReadLocalStorage(key: string): string | null { function safeReadLocalStorage(key: string): string | null {
try { try {
if (typeof window === "undefined" || !window.localStorage) return null if (typeof window === "undefined" || !window.localStorage) return null

View File

@@ -1,4 +1,4 @@
@import "github-markdown-css/github-markdown-dark.css"; @import "github-markdown-css/github-markdown-light.css" layer(github-markdown-base);
@layer components { @layer components {
.markdown-body { .markdown-body {
@@ -108,17 +108,23 @@
background: transparent; background: transparent;
} }
.markdown-body pre { .markdown-body pre:not(.shiki) {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
background-color: var(--surface-code); background-color: var(--surface-code);
color: var(--text-primary);
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
margin: 1rem 0; margin: 1rem 0;
} }
.markdown-body pre:not(.shiki) code,
.markdown-code-block pre:not(.shiki) code {
color: var(--text-primary);
}
.markdown-body blockquote { .markdown-body blockquote {
border-left: 3px solid var(--border-base); border-left: 3px solid var(--border-base);
color: var(--text-secondary); color: var(--text-secondary);
@@ -151,16 +157,6 @@
width: 100%; width: 100%;
margin: 1rem 0; margin: 1rem 0;
background-color: transparent; background-color: transparent;
display: block;
padding-right: 0.75rem;
}
.markdown-body thead,
.markdown-body tbody,
.markdown-body tfoot {
width: 100%;
display: table;
table-layout: fixed;
} }
.markdown-body th, .markdown-body th,
@@ -168,12 +164,22 @@
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
color: var(--text-primary);
background-color: transparent;
} }
.markdown-body th { .markdown-body th {
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
} }
.markdown-body tbody > tr:nth-child(odd) > td {
background-color: var(--markdown-table-row-odd);
}
.markdown-body tbody > tr:nth-child(even) > td {
background-color: var(--markdown-table-row-even);
}
.markdown-code-block { .markdown-code-block {
position: relative; position: relative;
margin: 10px 0; margin: 10px 0;

View File

@@ -46,6 +46,11 @@
color: var(--text-primary); color: var(--text-primary);
} }
.modal-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover { .modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
background-color: var(--surface-hover); background-color: var(--surface-hover);
} }

View File

@@ -6,6 +6,8 @@
--surface-muted: #f8fafc; --surface-muted: #f8fafc;
--surface-code: #f1f5f9; --surface-code: #f1f5f9;
--surface-hover: #e0e0e0; --surface-hover: #e0e0e0;
--markdown-table-row-odd: transparent;
--markdown-table-row-even: #f1f5f9;
/* Border tokens */ /* Border tokens */
--border-base: #e0e0e0; --border-base: #e0e0e0;
@@ -180,6 +182,8 @@
--surface-muted: #212529; --surface-muted: #212529;
--surface-code: #1a1a1a; --surface-code: #1a1a1a;
--surface-hover: #3a3a3a; --surface-hover: #3a3a3a;
--markdown-table-row-odd: #0f1114;
--markdown-table-row-even: #181c22;
/* Border tokens */ /* Border tokens */
--border-base: #3a3a3a; --border-base: #3a3a3a;
@@ -347,6 +351,8 @@
--surface-muted: #212529; --surface-muted: #212529;
--surface-code: #1a1a1a; --surface-code: #1a1a1a;
--surface-hover: #3a3a3a; --surface-hover: #3a3a3a;
--markdown-table-row-odd: #0f1114;
--markdown-table-row-even: #181c22;
/* Border tokens */ /* Border tokens */
--border-base: #3a3a3a; --border-base: #3a3a3a;

View File

@@ -153,6 +153,19 @@
@apply opacity-50; @apply opacity-50;
} }
/*
Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on
touch-first devices and in WebUI where browser shortcuts often conflict.
*/
html[data-runtime-host="web"] .keyboard-hints,
html[data-runtime-host="web"] .kbd-hint,
html[data-runtime-platform="mobile"] .keyboard-hints,
html[data-runtime-platform="mobile"] .kbd-hint,
html[data-keyboard-hints="hide"] .keyboard-hints,
html[data-keyboard-hints="hide"] .kbd-hint {
display: none !important;
}
/* Truncate from the start (keeps end visible; good for paths) */ /* Truncate from the start (keeps end visible; good for paths) */
.truncate-start { .truncate-start {
overflow: hidden; overflow: hidden;