Compare commits

...

82 Commits

Author SHA1 Message Date
Shantur Rathore
0e5695a903 ui: emphasize command palette button 2026-02-20 00:24:24 +00:00
Shantur Rathore
77103b7292 ui: use Check icon for completed status 2026-02-20 00:14:02 +00:00
Shantur Rathore
b14a144ddc ui: use lucide status icons for tool calls 2026-02-20 00:08:07 +00:00
Shantur Rathore
8ac67311d8 ui: use emoji status icons for tool calls 2026-02-19 23:51:33 +00:00
Shantur Rathore
0c97db393c fix(ui): expand read tool calls on error 2026-02-19 21:16:14 +00:00
Shantur Rathore
614c300d2f ui: default tool input visibility to collapsed 2026-02-19 21:12:39 +00:00
Shantur Rathore
e6ca4bd43d fix(ui): let palette tool input visibility override per-call 2026-02-19 20:46:46 +00:00
Shantur Rathore
84f81cf829 ui: left-align tool IO header text 2026-02-19 20:44:22 +00:00
Shantur Rathore
3760ba2d7f fix(ui): scope tool input toggle to current tool call 2026-02-19 20:42:23 +00:00
Shantur Rathore
09e7a3f8da feat(ui): add tool input visibility preference 2026-02-19 20:37:48 +00:00
Shantur Rathore
c55d56c94b ui: remove semibold from IO headers 2026-02-19 18:44:57 +00:00
Shantur Rathore
cc53123bcd ui: remove extra padding around IO sections 2026-02-19 18:44:29 +00:00
Shantur Rathore
d64027d43d ui: refine tool IO accordion styling 2026-02-19 18:43:06 +00:00
Shantur Rathore
6b7162f50f ui: add input/output accordions in tool calls 2026-02-19 18:37:46 +00:00
Shantur Rathore
5fd985f0c2 ui: rename tool input toggle and add IO headers 2026-02-19 18:31:41 +00:00
Shantur Rathore
2a438b2bb3 feat(ui): toggle tool call input yaml 2026-02-19 18:09:16 +00:00
Shantur Rathore
e84adebe61 fix(server): detect OpenCode version via spawn spec 2026-02-19 07:24:14 +00:00
Shantur Rathore
d45a1ff078 Bump to v0.11.3 2026-02-18 19:59:54 +00:00
Shantur Rathore
b4121696bb fix(ui): track worktree context for question replies
Store the originating worktree slug when questions are enqueued and use
the stored worktree client when replying/rejecting from the global
permission center. This ensures question responses are sent through the
correct worktree, matching the behavior already implemented for
permissions.
2026-02-18 19:56:42 +00:00
Shantur Rathore
f75c942162 fix(ui): exclude hidden agents from pickers 2026-02-18 16:00:58 +00:00
Shantur Rathore
127a1f628d feat(server,ui): allow OpenCode directory override via proxy path 2026-02-18 09:43:30 +00:00
Shantur Rathore
859312ba3b feat(ui): add dispose instance and rehydrate
Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
2026-02-18 01:07:52 +00:00
Shantur Rathore
4eaa711f01 fix(ui): make alert dialog scrollable for long errors 2026-02-18 00:27:26 +00:00
Shantur Rathore
c8ff858565 fix(ui): render user message text as markdown
User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors.
2026-02-17 22:44:30 +00:00
Shantur Rathore
6de6ef5a4a Bump to v0.11.2 2026-02-17 18:47:21 +00:00
Shantur Rathore
4dee154490 docs: add star history chart 2026-02-17 18:43:02 +00:00
Shantur Rathore
ef388adc4f fix(server): avoid back to login after auth
Replace /login history entry on success and redirect authenticated /login to /, with no-store headers to prevent caching.
2026-02-17 18:27:41 +00:00
Shantur Rathore
e8cfad1266 fix(ui): anchor fullscreen exit button to viewport
Render the mobile fullscreen exit button at the App root so fixed positioning stays pinned to the top-right regardless of instance header visibility.
2026-02-17 18:13:44 +00:00
Shantur Rathore
3f82dd21fe fix(ui): reduce prompt expanded height on mobile
Use the existing instance shell layout mode to cap expanded prompt rows to 10 on phone/tablet while keeping 15 on desktop.
2026-02-17 18:04:37 +00:00
Shantur Rathore
dc13d9a7d0 fix(ui): avoid mobile prompt focus on switch
Stops auto-focusing the prompt on phone session switches and scopes type-to-focus to the active visible prompt, disabling it on coarse pointers.
2026-02-17 18:00:48 +00:00
Shantur Rathore
29557fba6d feat(ui): add mobile fullscreen mode
Adds an in-memory mobile fullscreen toggle that hides chrome and uses the Fullscreen API when available.
2026-02-17 17:30:03 +00:00
Shantur Rathore
dea5079713 feat(ui): add diff toolbar toggles and word wrap
Replace split/unified and context controls with icon toggles, add a word-wrap toggle (default on), and move the toolbar into the tab header to free vertical space.
2026-02-17 13:47:07 +00:00
Shantur Rathore
ddc58a2c3c feat(ui): add context meter indicator
Replace duplicated Used/Avail pills with a shared ContextMeter component and add a small filled context usage indicator for quick scanning.
2026-02-17 12:26:03 +00:00
Shantur Rathore
eafd4d83af fix(ui): use model input limit for avail tokens
Upgrade @opencode-ai/sdk to 1.2.6 and prefer v2 model limit.input when present for the session AVAIL chip; otherwise keep the existing context-window-based estimate.
2026-02-17 11:13:17 +00:00
Shantur Rathore
1a0734c6b1 fix(ui): persist listening mode before restart 2026-02-16 21:39:46 +00:00
Shantur Rathore
e16c5752ed Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-16 09:01:25 +00:00
Shantur Rathore
375f92410e Merge pull request #169 from NeuralNomadsAI/codenomad/issue-136
feat(ui): unify picker Tab/Enter/Shift+Enter and allow directory attachments
2026-02-16 09:00:22 +00:00
Shantur Rathore
53f1dd4150 Merge pull request #171 from VooDisss/codenomad/issue-136
fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling
2026-02-16 08:59:17 +00:00
VooDisss
b7f638f07d fix(i18n): add workspace root translation key 2026-02-16 05:21:22 +02:00
VooDisss
32113ea100 fix(ui): resolve root path @. and @./ correctly 2026-02-16 05:03:27 +02:00
VooDisss
b31135f622 fix(ui): fix ./ path prefix for SHIFT+ENTER 2026-02-16 04:29:24 +02:00
Shantur Rathore
eb6701185b Min version 0.11.1 2026-02-15 23:36:32 +00:00
Shantur Rathore
d948ad8e35 Bump version to 0.11.1 2026-02-15 23:34:26 +00:00
VooDisss
f58267dd30 fix(ui): always strip @ for SHIFT+ENTER paths regardless of file attachment 2026-02-16 01:23:24 +02:00
VooDisss
95c747923c fix(ui): improve picker actions, directory navigation, @ handling, and message display 2026-02-16 01:11:53 +02:00
Shantur Rathore
f3b9ee4e04 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-15 22:52:48 +00:00
Shantur Rathore
309a123c1f Merge pull request #176 from NeuralNomadsAI/codenomad/issue-175
fix(ui): prevent close button overlapping theme toggle
2026-02-15 22:45:49 +00:00
Shantur Rathore
761e3d4268 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev
# Conflicts:
#	packages/ui/src/stores/preferences.tsx
2026-02-15 22:43:18 +00:00
Shantur Rathore
265d497ef4 chore(opencode-config): bump @opencode-ai/plugin to 1.2.4 2026-02-15 22:26:17 +00:00
Shantur Rathore
56a052086f fix(ui): ignore unsupported patch parts 2026-02-15 22:26:17 +00:00
Shantur Rathore
9a4d205d97 refactor(ui): rename message time.completed to time.end
Update all references from info.time.completed to info.time.end to align
with SDK schema changes. Affects message status tracking and rendering.
2026-02-15 20:38:57 +00:00
Shantur Rathore
ff71302969 fix(ui): prevent close button overlapping theme toggle 2026-02-15 15:43:54 +00:00
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
5067db3dd0 fix(ui): handle message.part.delta streaming
Wire message.part.delta SSE events into the v2 message store and append deltas onto existing part fields.
2026-02-15 00:54:31 +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
1ef01da019 feat(ui): improve picker actions and directory attach 2026-02-13 22:52:42 +00:00
Shantur Rathore
edd3ded1d8 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-13 14:47:43 +00:00
Shantur Rathore
e30ff6358d feat(settings): move config/state to owner buckets
Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes.
2026-02-13 14:34:33 +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
Shantur Rathore
6ed1e09180 fix(ui): align sidebar header icon sizes 2026-02-12 16:07:54 +00:00
Shantur Rathore
54d4cf6604 fix(ui): use PlusSquare icon export 2026-02-12 15:47:50 +00:00
Shantur Rathore
359e89971f feat(ui): add new session icon in sidebar header 2026-02-12 15:37:58 +00:00
Shantur Rathore
7f833747b0 Merge pull request #160 from NeuralNomadsAI/codenomad/issue-157
fix(ui): handle Windows paths in instance tab titles
2026-02-12 15:01:44 +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
153 changed files with 4453 additions and 1506 deletions

View File

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

View File

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

View File

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

View File

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

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
```
For dev version
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad@dev --launch
npx @neuralnomads/codenomad --help
```
This command starts the server and opens the web client in your default browser.
### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
## Highlights
@@ -115,3 +123,6 @@ To build the Desktop App from source:
1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.10.3",
"version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.10.3",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -2809,9 +2809,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@pinojs/redact": {
@@ -11879,6 +11879,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"dev": true,
@@ -11970,11 +11985,12 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.3",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
"@neuralnomads/codenomad": "file:../server",
"yaml": "^2.4.2"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
@@ -12005,7 +12021,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12017,6 +12033,7 @@
"node-forge": "^1.3.3",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
@@ -12045,7 +12062,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.10.3",
"version": "0.11.3",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12053,12 +12070,12 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.10.3",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11",
"@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
@@ -12075,7 +12092,8 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.10.3",
"version": "0.11.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.3",
"version": "0.11.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
@@ -36,7 +36,8 @@
},
"dependencies": {
"@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui"
"@codenomad/ui": "file:../ui",
"yaml": "^2.4.2"
},
"devDependencies": {
"7zip-bin": "^5.2.0",

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.1.53"
"@opencode-ai/plugin": "1.2.6"
}
}

View File

@@ -1 +1,4 @@
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
```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags
You can configure the server using flags or environment variables:
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--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-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--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 |
| `--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

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"version": "0.11.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"version": "0.11.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -34,6 +34,7 @@
"node-forge": "^1.3.3",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},

View File

@@ -1,7 +1,6 @@
import type {
AgentModelSelection,
AgentModelSelections,
ConfigFile,
ModelPreference,
OpenCodeBinary,
Preferences,
@@ -183,9 +182,9 @@ export interface BinaryRecord {
validationError?: string
}
export type AppConfig = ConfigFile
export type AppConfigResponse = AppConfig
export type AppConfigUpdateRequest = Partial<AppConfig>
export type SettingsOwner = string
export type SettingsBucket = Record<string, unknown>
export type SettingsDoc = Record<string, unknown>
export interface BinaryListResponse {
binaries: BinaryRecord[]
@@ -214,8 +213,8 @@ export type WorkspaceEventType =
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "config.appChanged"
| "config.binariesChanged"
| "storage.configChanged"
| "storage.stateChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
@@ -226,8 +225,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
@@ -286,6 +285,8 @@ export interface ServerMeta {
serverVersion?: string
ui?: UiMeta
support?: SupportMeta
/** Optional update info (dev channel only). */
update?: LatestReleaseInfo | null
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -1,192 +0,0 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
private readonly configStore: ConfigStore,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
this.logger.warn("No configured binaries found, falling back to opencode")
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.replace(nextConfig)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
if (updates.makeDefault) {
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.replace(nextConfig)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.replace(nextConfig)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
this.logger.debug({ path }, "Validating OpenCode binary path")
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

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

View File

@@ -1,78 +0,0 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private loaded = false
constructor(
private readonly configPath: string,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const resolved = this.resolvePath(this.configPath)
if (fs.existsSync(resolved)) {
const content = fs.readFileSync(resolved, "utf-8")
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed)
this.logger.debug({ resolved }, "Loaded existing config file")
} else {
this.cache = DEFAULT_CONFIG
this.logger.debug({ resolved }, "No config file found, using defaults")
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults")
this.cache = DEFAULT_CONFIG
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}

View File

@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("storage.configChanged", handler)
this.on("storage.stateChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("storage.configChanged", handler)
this.off("storage.stateChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)

View File

@@ -8,8 +8,9 @@ import { fileURLToPath } from "url"
import { createRequire } from "module"
import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
import { BinaryRegistry } from "./config/binaries"
import { resolveConfigLocation } from "./config/location"
import { SettingsService } from "./settings/service"
import { BinaryResolver } from "./settings/binaries"
import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
@@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
const require = createRequire(import.meta.url)
@@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string {
return trimmed
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
@@ -245,7 +240,8 @@ async function main() {
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath))
const configLocation = resolveConfigLocation(options.configPath)
const configDir = configLocation.baseDir
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
@@ -266,7 +262,7 @@ async function main() {
const authManager = new AuthManager(
{
configPath: options.configPath,
configPath: configLocation.configYamlPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
@@ -295,19 +291,19 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const settings = new SettingsService(configLocation, eventBus, configLogger)
const binaryResolver = new BinaryResolver(settings)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
settings,
binaryResolver,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceStore = new InstanceStore(configLocation.instancesDir)
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -344,6 +340,21 @@ async function main() {
minServerVersion: uiResolution.minServerVersion,
}
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
const devReleaseMonitor = enableDevUpdateChecks
? startDevReleaseMonitor({
currentVersion: packageJson.version,
repo: githubRepo,
logger: logger.child({ component: "updates" }),
onUpdate: (release) => {
serverMeta.update = release
},
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
@@ -372,8 +383,7 @@ async function main() {
defaultPort: options.httpPort,
protocol: "http",
workspaceManager,
configStore,
binaryRegistry,
settings,
fileSystemBrowser,
eventBus,
serverMeta,
@@ -393,8 +403,7 @@ async function main() {
protocol: "https",
httpsOptions: tlsResolution?.httpsOptions,
workspaceManager,
configStore,
binaryRegistry,
settings,
fileSystemBrowser,
eventBus,
serverMeta,
@@ -503,6 +512,8 @@ async function main() {
// no-op: remote UI manifest replaces GitHub release monitor
devReleaseMonitor?.stop()
logger.info("Exiting process")
process.exit(0)
}

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> {
const response = await fetch(RELEASES_API_URL, {
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
const trimmed = tag.trim()
if (!trimmed) return null
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
function parseVersion(value: string): NormalizedVersion {
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 parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0

View File

@@ -9,12 +9,11 @@ import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerConfigRoutes } from "./routes/config"
import { registerSettingsRoutes } from "./routes/settings"
import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
@@ -37,8 +36,7 @@ interface HttpServerDeps {
protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
settings: SettingsService
fileSystemBrowser: FileSystemBrowser
eventBus: EventBus
serverMeta: ServerMeta
@@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) {
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
@@ -369,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: {
request: FastifyRequest
reply: FastifyReply
@@ -459,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
try {
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
if (extracted.overrideDirectory) {
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -535,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
})
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"

View File

@@ -119,7 +119,8 @@
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
// Replace history entry so Back doesn't return to /login.
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}

View File

@@ -51,7 +51,19 @@ function getTokenHtml(): string {
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
app.get("/login", async (request, reply) => {
// If already authenticated, don't show the login page.
const session = deps.authManager.getSessionFromRequest(request)
if (session) {
reply.redirect("/")
return
}
// Avoid caching the login page (helps with bfcache/back behavior).
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
return
}
// Avoid caching the token bootstrap page.
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(getTokenHtml())
})

View File

@@ -1,62 +0,0 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps {
configStore: ConfigStore
binaryRegistry: BinaryRegistry
}
const BinaryCreateSchema = z.object({
path: z.string(),
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryUpdateSchema = z.object({
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryValidateSchema = z.object({
path: z.string(),
})
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.replace(body)
return deps.configStore.get()
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -0,0 +1,80 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
interface RouteDeps {
settings: SettingsService
logger: Logger
}
const ValidateBinarySchema = z.object({
path: z.string(),
})
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.error }
}
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
app.patch("/api/storage/state", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("state", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
return deps.settings.getOwner("state", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
// Binary validation helper (used by UI when adding binaries)
app.post("/api/storage/binaries/validate", async (request, reply) => {
try {
const body = ValidateBinarySchema.parse(request.body ?? {})
return validateBinaryPath(body.path)
} catch (error) {
deps.logger.warn({ err: error }, "Failed to validate binary")
reply.code(400)
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
}
})
}

View File

@@ -0,0 +1,55 @@
import type { SettingsService } from "./service"
export interface OpenCodeBinaryEntry {
path: string
version?: string
lastUsed?: number
label?: string
}
export interface ResolvedBinary {
path: string
label: string
version?: string
}
function prettyLabel(p: string): string {
const parts = p.split(/[\\/]/)
const last = parts[parts.length - 1] || p
return last || p
}
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
const ui = settings.getOwner("state", "ui")
const list = (ui as any)?.opencodeBinaries
if (!Array.isArray(list)) return []
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
}
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
const server = settings.getOwner("config", "server")
const value = (server as any)?.opencodeBinary
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
}
export class BinaryResolver {
constructor(private readonly settings: SettingsService) {}
list(): OpenCodeBinaryEntry[] {
return readUiBinaries(this.settings)
}
resolveDefault(): ResolvedBinary {
const binaries = this.list()
const configuredDefault = readDefaultBinaryPath(this.settings)
const fallback = binaries[0]?.path
const path = configuredDefault ?? fallback ?? "opencode"
const entry = binaries.find((b) => b.path === path)
return {
path,
label: entry?.label ?? prettyLabel(path),
version: entry?.version,
}
}
}

View File

@@ -0,0 +1,39 @@
type PlainObject = Record<string, unknown>
export function isPlainObject(value: unknown): value is PlainObject {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* RFC 7396-ish merge patch with explicit null deletes.
* - Objects merge recursively
* - Arrays/scalars replace
* - null deletes keys
*/
export function applyMergePatch(current: unknown, patch: unknown): unknown {
if (!isPlainObject(patch)) {
return patch
}
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
const existing = base[key]
if (isPlainObject(value) && isPlainObject(existing)) {
base[key] = applyMergePatch(existing, value)
continue
}
base[key] = value
}
return base
}

View File

@@ -0,0 +1,269 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import type { ConfigLocation } from "../config/location"
import { isPlainObject } from "./merge-patch"
type Doc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function safeReadYaml(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return parseYaml(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
return null
}
}
function safeReadJson(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return JSON.parse(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
return null
}
}
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const yaml = stringifyYaml(doc as any)
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
}
}
function pickBackupPath(filePath: string): string {
const preferred = `${filePath}.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${filePath}.bak.${Date.now()}`
}
function normalizeDoc(value: unknown): Doc {
return isPlainObject(value) ? (value as Doc) : {}
}
function looksLikeNewOwnerDoc(value: unknown): boolean {
const doc = normalizeDoc(value)
// Heuristic: owner-bucket docs have at least one of these roots.
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
}
function looksLikeLegacyConfig(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
}
function looksLikeLegacyState(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.recentFolders)
}
function omitKeys(source: Doc, keys: Set<string>): Doc {
const out: Doc = {}
for (const [k, v] of Object.entries(source)) {
if (keys.has(k)) continue
out[k] = v
}
return out
}
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
const cfg = normalizeDoc(legacyConfig)
const st = normalizeDoc(legacyState)
const outConfig: Doc = {}
const outState: Doc = {}
const uiConfig: Doc = {}
const uiSettings: Doc = {}
const serverConfig: Doc = {}
const uiState: Doc = {}
// theme -> config.ui.theme
if (typeof cfg.theme === "string") {
uiConfig.theme = cfg.theme
}
const preferences = normalizeDoc(cfg.preferences)
if (Object.keys(preferences).length > 0) {
// Server-owned stable keys
const envVars = preferences.environmentVariables
if (isPlainObject(envVars)) {
serverConfig.environmentVariables = envVars
}
const listeningMode = preferences.listeningMode
if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode
}
const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary
}
// UI-owned state keys (drop preferences)
const modelRecents = preferences.modelRecents
const modelFavorites = preferences.modelFavorites
const modelThinkingSelections = preferences.modelThinkingSelections
const models: Doc = {}
if (Array.isArray(modelRecents)) {
models.recents = modelRecents
}
if (Array.isArray(modelFavorites)) {
models.favorites = modelFavorites
}
if (isPlainObject(modelThinkingSelections)) {
models.thinkingSelections = modelThinkingSelections
}
if (Object.keys(models).length > 0) {
uiState.models = models
}
// Remaining preferences are treated as stable UI settings.
const moved = new Set([
"environmentVariables",
"listeningMode",
"lastUsedBinary",
"modelRecents",
"modelFavorites",
"modelThinkingSelections",
])
Object.assign(uiSettings, omitKeys(preferences, moved))
}
// recentFolders lives in legacy state (yaml) or legacy config.json
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
if (Array.isArray(recentFolders)) {
uiState.recentFolders = recentFolders
}
// opencodeBinaries -> state.ui.opencodeBinaries
if (Array.isArray(cfg.opencodeBinaries)) {
uiState.opencodeBinaries = cfg.opencodeBinaries
}
if (Object.keys(uiSettings).length > 0) {
uiConfig.settings = uiSettings
}
if (Object.keys(uiConfig).length > 0) {
outConfig.ui = uiConfig
}
if (Object.keys(serverConfig).length > 0) {
outConfig.server = serverConfig
}
if (Object.keys(uiState).length > 0) {
outState.ui = uiState
}
// Unknown top-level keys -> legacy.unknown
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
const unknownConfig = omitKeys(cfg, knownConfigKeys)
if (Object.keys(unknownConfig).length > 0) {
outConfig.legacy = { unknown: unknownConfig }
}
const knownStateKeys = new Set(["recentFolders"])
const unknownState = omitKeys(st, knownStateKeys)
if (Object.keys(unknownState).length > 0) {
outState.legacy = { unknown: unknownState }
}
return { config: outConfig, state: outState }
}
/**
* Migrate older config/state layouts into owner-bucket YAML docs.
*
* Legacy inputs supported:
* - config.yaml with { preferences, opencodeBinaries, theme }
* - state.yaml with { recentFolders }
* - legacy config.json with full ConfigFile schema
*/
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
const configYamlPath = location.configYamlPath
const stateYamlPath = location.stateYamlPath
const legacyJsonPath = location.legacyJsonPath
const configExists = fs.existsSync(configYamlPath)
const stateExists = fs.existsSync(stateYamlPath)
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
if (configIsNew && stateIsNew) {
return
}
const legacyJsonExists = fs.existsSync(legacyJsonPath)
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
const shouldMigrateFromJson = !configExists && legacyJsonExists
if (!hasLegacyYaml && !shouldMigrateFromJson) {
// Either fresh install or partially written docs; let stores create on first write.
return
}
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
try {
fs.mkdirSync(location.baseDir, { recursive: true })
} catch (error) {
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
}
// Backup legacy files before rewriting.
if (configExists) {
try {
const bak = pickBackupPath(configYamlPath)
fs.renameSync(configYamlPath, bak)
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
} catch (error) {
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
}
}
if (stateExists) {
try {
const bak = pickBackupPath(stateYamlPath)
fs.renameSync(stateYamlPath, bak)
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
} catch (error) {
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
}
}
if (shouldMigrateFromJson) {
try {
const bak = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bak)
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
} catch (error) {
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
}
}
writeYaml(configYamlPath, config, logger)
writeYaml(stateYamlPath, state, logger)
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
}

View File

@@ -0,0 +1,55 @@
import type { Logger } from "../logger"
import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
export type DocKind = "config" | "state"
export class SettingsService {
private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore
constructor(
private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {
migrateSettingsLayout(location, logger)
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
}
getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get()
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
} as any
this.eventBus.publish(payload)
}
}

View File

@@ -0,0 +1,110 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import { applyMergePatch, isPlainObject } from "./merge-patch"
export type SettingsDoc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function normalizeDoc(input: unknown): SettingsDoc {
if (!isPlainObject(input)) {
return {}
}
return input
}
export class YamlDocStore {
private cache: SettingsDoc = {}
private loaded = false
constructor(
private readonly filePath: string,
private readonly logger: Logger,
) {}
load(): SettingsDoc {
if (this.loaded) {
return this.cache
}
try {
if (!fs.existsSync(this.filePath)) {
this.cache = {}
this.loaded = true
return this.cache
}
const content = fs.readFileSync(this.filePath, "utf-8")
const parsed = parseYaml(content)
this.cache = normalizeDoc(parsed)
this.loaded = true
return this.cache
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
this.cache = {}
this.loaded = true
return this.cache
}
}
get(): SettingsDoc {
return this.load()
}
replace(next: unknown): SettingsDoc {
const normalized = normalizeDoc(next)
this.cache = normalized
this.loaded = true
this.persist()
return this.cache
}
mergePatch(patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current, patch)
return this.replace(next)
}
getOwner(owner: string): SettingsDoc {
const doc = this.get()
const value = (doc as any)?.[owner]
return normalizeDoc(value)
}
replaceOwner(owner: string, value: unknown): SettingsDoc {
const doc = this.get()
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
this.replace(nextDoc)
return nextDoc[owner] as SettingsDoc
}
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const doc = this.get()
const currentOwner = normalizeDoc((doc as any)?.[owner])
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
this.replace(nextDoc)
return nextOwner
}
private persist() {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
const yaml = stringifyYaml(this.cache as any)
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
}
}
}

View File

@@ -2,13 +2,13 @@ import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import type { SettingsService } from "../settings/service"
import type { BinaryResolver } from "../settings/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
settings: SettingsService
binaryResolver: BinaryResolver
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
@@ -86,7 +86,7 @@ export class WorkspaceManager {
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const binary = this.options.binaryResolver.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
@@ -118,8 +118,9 @@ export class WorkspaceManager {
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const serverConfig = this.options.settings.getOwner("config", "server")
const envVars = (serverConfig as any)?.environmentVariables
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
@@ -282,28 +283,22 @@ export class WorkspaceManager {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
const result = probeBinaryVersion(resolvedPath)
if (result.valid) {
if (result.version) {
this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
return result.version
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
if (result.reported) {
this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
return result.reported
}
return undefined
}
if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
}
return undefined
}

View File

@@ -8,6 +8,8 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const }
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.10.3",
"version": "0.11.3",
"private": true,
"license": "MIT",
"scripts": {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.10.3",
"version": "0.11.3",
"private": true,
"license": "MIT",
"type": "module",
@@ -13,7 +13,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11",
"@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
@@ -30,7 +30,8 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
@@ -58,8 +60,10 @@ const App: Component = () => {
const { t } = useI18n()
const {
preferences,
serverSettings,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
@@ -68,6 +72,7 @@ const App: Component = () => {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
@@ -80,12 +85,109 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
// In-memory only: hides chrome on phone; may also request browser fullscreen.
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
const fullscreenSupported = () => {
if (typeof document === "undefined") return false
const el = document.documentElement as any
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
}
const syncBrowserFullscreenState = () => {
if (typeof document === "undefined") return
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
}
const enterMobileFullscreen = async () => {
if (!isPhoneLayout()) return
setMobileFullscreenMode(true)
if (!fullscreenSupported()) return
try {
await document.documentElement.requestFullscreen()
} catch {
// Ignore: immersive mode still works without browser fullscreen.
}
}
const exitMobileFullscreen = async () => {
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
try {
await document.exitFullscreen()
} catch {
// Ignore
}
}
setMobileFullscreenMode(false)
}
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
onMount(() => {
if (typeof document === "undefined") return
syncBrowserFullscreenState()
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
})
onMount(() => {
if (typeof window === "undefined") return
const vv = window.visualViewport
if (!vv) return
const updateKeyboardOffset = () => {
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
}
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
schedule()
vv.addEventListener("resize", schedule)
vv.addEventListener("scroll", schedule)
window.addEventListener("orientationchange", schedule)
onCleanup(() => {
vv.removeEventListener("resize", schedule)
vv.removeEventListener("scroll", schedule)
window.removeEventListener("orientationchange", schedule)
document.documentElement.style.removeProperty("--keyboard-offset")
})
})
// If the user exits browser fullscreen via browser UI, restore chrome.
let lastBrowserFullscreen = false
createEffect(() => {
const active = browserFullscreenActive()
const mode = mobileFullscreenMode()
if (mode && lastBrowserFullscreen && !active) {
setMobileFullscreenMode(false)
}
lastBrowserFullscreen = active
})
// If we leave phone layout (rotation / resize), restore chrome.
createEffect(() => {
if (!isPhoneLayout() && mobileFullscreenMode()) {
void exitMobileFullscreen()
}
})
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
@@ -177,7 +279,7 @@ const App: Component = () => {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
try {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
@@ -293,6 +395,7 @@ const App: Component = () => {
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
@@ -300,6 +403,7 @@ const App: Component = () => {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
@@ -395,19 +499,34 @@ const App: Component = () => {
</div>
</Dialog.Portal>
</Dialog>
<div class="h-screen w-screen flex flex-col">
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
<div class="mobile-fullscreen-exit-wrapper">
<button
type="button"
class="message-scroll-button mobile-fullscreen-exit-button"
onClick={() => void exitMobileFullscreen()}
aria-label={t("instanceShell.fullscreen.exit")}
title={t("instanceShell.fullscreen.exit")}
>
<Minimize2 class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
<For each={Array.from(instances().values())}>
{(instance) => {
@@ -425,7 +544,10 @@ const App: Component = () => {
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>
@@ -451,25 +573,17 @@ const App: Component = () => {
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative">
<button
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
</div>
</div>

View File

@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
const availableAgents = createMemo(() => {
const allAgents = instanceAgents()
if (isChildSession()) {
return allAgents
return allAgents.filter((agent) => !agent.hidden)
}
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
>
<div class="flex-1 min-w-0">
<Select.Value<Agent>>
{(state) => (
{() => (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
</span>
</div>
)}

View File

@@ -115,28 +115,36 @@ const AlertDialog: Component = () => {
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content
class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
tabIndex={-1}
>
<div class="flex items-start gap-3 min-h-0">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0 min-h-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
<div
class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
style={{ "overflow-wrap": "anywhere" }}
>
{payload.message}
{payload.detail && <div class="mt-3">{payload.detail}</div>}
</div>
</Dialog.Description>
</div>
</div>
<Show when={isPrompt}>
<div class="mt-4">
@@ -185,14 +193,14 @@ const AlertDialog: Component = () => {
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

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

View File

@@ -0,0 +1,123 @@
import type { Component } from "solid-js"
interface ContextMeterProps {
usedTokens: number
availableTokens: number | null
formatTokens: (value: number) => string
usedLabel: string
availableLabel: string
class?: string
}
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveFillColor(percent: number): string {
if (percent >= 0.8) return "var(--status-error)"
if (percent >= 0.6) return "var(--status-warning)"
return "var(--status-success)"
}
export const ContextMeter: Component<ContextMeterProps> = (props) => {
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
const percent = () => {
const usedValue = used()
const availableValue = available()
if (availableValue === null || availableValue <= 0) return null
// Heuristic: if available >= used, treat it like a capacity/limit.
// Otherwise treat it like remaining tokens.
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
return clamp(ratio, 0, 1)
}
const fillColor = () => {
const value = percent()
if (value === null) return "var(--border-base)"
return resolveFillColor(value)
}
const percentLabel = () => {
const value = percent()
if (value === null) return "--"
return `${Math.round(value * 100)}%`
}
const containerClass =
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
}
}
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, startAngle)
const end = polarToCartesian(cx, cy, r, endAngle)
const delta = ((endAngle - startAngle) % 360 + 360) % 360
const largeArc = delta > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
const circle = () => {
const value = percent()
const size = 22
const r = 9
const cx = 11
const cy = 11
const progress = value === null ? 0 : value
const startAngle = -90
const endAngle = startAngle + progress * 360
const isFull = progress >= 0.999
const hasFill = progress > 0.001
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
return (
<svg
width={size}
height={size}
viewBox="0 0 22 22"
aria-hidden="true"
style={{ flex: "0 0 auto" }}
>
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
{isFull ? (
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
) : sectorPath ? (
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
) : null}
</svg>
)
}
const tooltipText = () => `Context Used: ${percentLabel()}`
return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
</div>
)
}
export default ContextMeter

View File

@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n()
const {
preferences,
serverSettings,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")

View File

@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
after: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
scrollBeyondLastLine: false,
renderWhitespace: "selection",
fontSize: 13,
wordWrap: "off",
wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: false,
folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
@@ -81,6 +82,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
@@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
contextMode === "collapsed"
? { enabled: true }
: { enabled: false },
wordWrap,
})
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
})
createEffect(() => {

View File

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

View File

@@ -1,6 +1,6 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -23,14 +23,15 @@ interface FolderSelectionViewProps {
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -53,7 +54,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
const lastUsed = serverSettings().opencodeBinary
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
@@ -373,7 +374,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onClose?.()}
aria-label={t("app.launchError.close")}
title={t("app.launchError.closeTitle")}
>
<X class="w-4 h-4" />
</button>
</Show>
</div>
@@ -548,7 +560,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
</div>
@@ -573,7 +585,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</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">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
@@ -591,7 +603,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</Show>
<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>
</div>
</div>

View File

@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
interface HintRowProps {
children: JSX.Element
class?: string
ariaHidden?: boolean
}
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

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
</div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,14 +1,21 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
showDisposeButton?: boolean
}
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : []
})
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return (
<div class="panel">
<div class="panel-header">
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div>
</div>
)

View File

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

View File

@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status"
import { ShieldAlert } from "lucide-solid"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types"
import {
@@ -69,6 +70,11 @@ interface InstanceShellProps {
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void
tabBarOffset: number
// In-memory only: mobile immersive/fullscreen mode.
mobileFullscreenMode: boolean
onEnterMobileFullscreen: () => void
onExitMobileFullscreen: () => void
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
@@ -117,6 +123,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -349,16 +357,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
measureDrawerHost,
})
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
const formattedAvailableTokens = () => {
const avail = tokenStats().avail
if (typeof avail === "number") {
return formatTokenTotal(avail)
}
return "--"
}
const renderLeftPanel = () => {
if (leftPinned()) {
@@ -594,13 +592,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{renderLeftPanel()}
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={!mobileFullscreen()}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton
ref={setLeftToggleButtonEl}
@@ -626,14 +625,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-1">
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
@@ -647,6 +646,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span>
</div>
<Show when={!props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
@@ -661,20 +672,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div>
}
>
@@ -693,18 +699,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</Show>
<div class="ml-auto flex items-center session-header-hints">
@@ -720,7 +721,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
@@ -730,7 +731,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
<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" />
</span>
@@ -769,9 +770,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
</div>
</Show>
</Toolbar>
</AppBar>
</Show>
</Toolbar>
</AppBar>
</Show>
<Box
component="main"
@@ -808,6 +810,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -1,10 +1,10 @@
import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state"
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 { Search } from "lucide-solid"
import { PlusSquare, Search } from "lucide-solid"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector"
@@ -56,6 +55,20 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
@@ -71,7 +84,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
},
}}
>
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
<Search class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
@@ -152,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
<KeyboardHint
class="session-sidebar-selector-hints"
ariaHidden={true}
shortcuts={[
keyboardRegistry.get("open-agent-selector"),
keyboardRegistry.get("focus-model"),
keyboardRegistry.get("focus-variant"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
separator=" "
showDescription={false}
/>
</div>
</>
)}

View File

@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -195,6 +199,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}

View File

@@ -1,50 +1,61 @@
import type { Component } from "solid-js"
import type { DiffContextMode, DiffViewMode } from "../types"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps {
viewMode: DiffViewMode
contextMode: DiffContextMode
wordWrapMode: DiffWordWrapMode
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
return (
<div class="file-viewer-toolbar">
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
aria-pressed={props.viewMode === "split"}
onClick={() => props.onViewModeChange("split")}
class="file-viewer-toolbar-icon-button"
onClick={() => props.onViewModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
Split
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
aria-pressed={props.viewMode === "unified"}
onClick={() => props.onViewModeChange("unified")}
class="file-viewer-toolbar-icon-button"
onClick={() => props.onContextModeChange(nextContextMode())}
aria-label={contextModeTitle()}
title={contextModeTitle()}
>
Unified
{nextContextMode() === "collapsed" ? (
<FoldVertical class="h-4 w-4" aria-hidden="true" />
) : (
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
)}
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"}
onClick={() => props.onContextModeChange("collapsed")}
title="Hide unchanged regions"
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
</div>
)

View File

@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -18,8 +18,10 @@ interface ChangesTabProps {
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -77,14 +79,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
@@ -102,6 +96,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
)}
</Show>
@@ -182,6 +177,17 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
<div style={{ "margin-left": "auto" }}>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</div>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void
onRefresh: () => void
@@ -80,14 +82,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.selectedLoading()}
@@ -122,6 +116,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
)}
</Show>
@@ -237,6 +232,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
>
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"

View File

@@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

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

View File

@@ -1,14 +1,20 @@
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 Kbd from "./kbd"
import HintRow from "./hint-row"
const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[]
separator?: string
separator?: string | null
showDescription?: boolean
class?: string
ariaHidden?: boolean
}> = (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 {
const parts: string[] = []
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
return parts.join("+")
}
if (!desktopQuery()) return null
return (
<HintRow>
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
<For each={props.shortcuts}>
{(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>}
<Kbd shortcut={buildShortcutString(shortcut)} />
</>

View File

@@ -198,6 +198,16 @@ interface MessageContentItemProps {
onContentRendered?: () => void
}
function isSupportedPartType(part: unknown): boolean {
const type = (part as any)?.type
// Ignore part types the UI does not support rendering yet.
return !(typeof type === "string" && type === "patch")
}
function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file"
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -222,15 +232,9 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) break
resolved.push(part)
}
@@ -256,15 +260,9 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) {
return false
}
@@ -549,6 +547,9 @@ export default function MessageBlock(props: MessageBlockProps) {
}
orderedParts.forEach((part, partIndex) => {
if (!isSupportedPartType(part)) {
return
}
if (part.type === "tool") {
flushContent()
const partId = part.id

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps {
record: MessageRecord
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
// We only want to display the primary prompt text for the user message; other synthetic text
// parts should be hidden.
const primaryUserTextPartId = () => {
if (!isUser()) return null
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
return typeof firstText?.id === "string" ? firstText.id : null
}
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) {
}
if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener")
// Local filesystem URLs are not reliably downloadable from the message stream.
// We hide the download action for these chips.
return
}
@@ -151,7 +162,8 @@ export default function MessageItem(props: MessageItemProps) {
}
const info = props.messageInfo
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
const timeInfo = info?.time as { created: number; end?: number } | undefined
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
}
const handleRevert = () => {
@@ -372,6 +384,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered}
/>
)}
@@ -398,17 +411,20 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={!attachment.url?.startsWith("file://")}>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
title={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
<button
type="button"

View File

@@ -1,10 +1,8 @@
import { Show } from "solid-js"
import Kbd from "./kbd"
import ContextMeter from "./context-meter"
import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps {
usedTokens: number
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info">
<div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div>
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
<ContextMeter
usedTokens={props.usedTokens}
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
formatTokens={props.formatTokens}
usedLabel={t("messageListHeader.metrics.usedLabel")}
availableLabel={t("messageListHeader.metrics.availableLabel")}
/>
</div>
</div>
@@ -55,14 +51,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-shortcut-action">
<button
type="button"
class="connection-status-button"
class="connection-status-button command-palette-button"
onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
>
{t("messageListHeader.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
</span>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -13,23 +12,39 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
}
@@ -43,6 +58,11 @@ interface MessagePartProps {
return ""
}
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
@@ -77,20 +97,28 @@ interface MessagePartProps {
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
if (part.type === "text" && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version
text: (part as any).text,
synthetic: false,
version: (part as { version?: number }).version,
renderCache: (part as any).renderCache,
}
}
return {
id: part.id,
type: "text",
type: "text",
text: "",
synthetic: false
synthetic: false,
}
}
@@ -103,22 +131,18 @@ interface MessagePartProps {
<Switch>
<Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

View File

@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
<ul>
<li>
<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>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li>

View File

@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
const log = getLogger("session")
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of preferences().modelFavorites ?? []) {
for (const item of uiState().models.favorites ?? []) {
if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`)
}

View File

@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
opencodeBinaries,
addOpenCodeBinary,
removeOpenCodeBinary,
preferences,
updatePreferences,
serverSettings,
updateLastUsedBinary,
} = useConfig()
const [customPath, setCustomPath] = createSignal("")
const [validating, setValidating] = createSignal(false)
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
const lastUsedBinary = () => serverSettings().opencodeBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (validation.valid) {
addOpenCodeBinary(path, validation.version)
props.onBinaryChange(path)
updatePreferences({ lastUsedBinary: path })
updateLastUsedBinary(path)
setCustomPath("")
setValidationError(null)
} else {
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.disabled) return
if (path === props.selectedBinary) return
props.onBinaryChange(path)
updatePreferences({ lastUsedBinary: path })
updateLastUsedBinary(path)
}
function handleRemoveBinary(path: string, event: Event) {
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.selectedBinary === path) {
props.onBinaryChange("opencode")
updatePreferences({ lastUsedBinary: "opencode" })
updateLastUsedBinary("opencode")
}
}

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 UnifiedPicker from "./unified-picker"
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 Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
handleDrop,
syncAttachmentCounters,
handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
if (!attachment) return
handleExpandTextAttachment(attachment)
},
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef
if (textarea) {
@@ -166,24 +170,32 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null)
setSearchQuery("")
const instanceId = props.instanceId
const sessionId = props.sessionId
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
syncAttachmentCounters(prompt(), currentAttachments)
syncAttachmentCounters(prompt())
},
{ defer: true },
),
)
onMount(() => {
const isCoarsePointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
if (isCoarsePointer()) return
if (props.isActive === false) return
if (props.disabled) return
const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement
const activeElement = document.activeElement as HTMLElement | null
const isInputElement =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" ||
activeElement?.isContentEditable
Boolean(activeElement?.isContentEditable)
if (isInputElement) return
@@ -191,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return
const isSpecialKey =
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
e.key === "Tab" ||
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return
if (e.key.length === 1 && textareaRef && !props.disabled) {
textareaRef.focus()
const textarea = textareaRef
if (!textarea || textarea.disabled) return
// In session cache mode inactive panes are display:none; avoid stealing focus.
if (textarea.offsetParent === null) return
if (e.key.length === 1) {
textarea.focus()
}
}
document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown)
})
@@ -238,10 +259,10 @@ export default function PromptInput(props: PromptInputProps) {
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("", [])
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
} else {
syncAttachmentCounters("", currentAttachments)
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
}
@@ -434,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={expandState() === "expanded" ? 15 : 4}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
@@ -479,7 +500,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={

View File

@@ -1,5 +1,3 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]`
}
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
}
export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
}
export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
}
export function createMentionRegex() {
return /@(\S+)/g
}
export const pastedDisplayCounterRegex = /pasted #(\d+)/
export const imageDisplayCounterRegex = /Image #(\d+)/
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
export const imageDisplayCounterRegex = /Image #(\d+)/i
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
export function findHighestAttachmentCounters(currentPrompt: string) {
let highestPaste = 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())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {

View File

@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void
}
@@ -16,6 +17,12 @@ export interface PromptInputProps {
instanceId: string
instanceFolder: string
sessionId: string
// Used to scope global "type-to-focus" behavior.
isActive?: boolean
// Phone/tablet layouts should keep the expanded prompt more compact.
compactLayout?: boolean
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean

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

View File

@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
)
const attachment = currentAttachments.find((a) => {
if (a.source.type === "agent") {
return a.filename === name
}
if (a.source.type === "file") {
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
return (
a.filename === name ||
a.source.path === name ||
a.source.path.endsWith("/" + name) ||
a.source.path === name.replace(/\/$/, "")
)
}
if (a.source.type === "text") {
// For text attachments (path-only mentions), match by value
return a.source.value === name || a.source.value.endsWith("/" + name)
}
return false
})
if (attachment) {
e.preventDefault()
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart)
}, 0)
// Check if there are any @ remaining in the text - if not, close the picker
if (!newText.includes("@") && options.isPickerOpen()) {
options.closePicker()
// Clear ignoredAtPositions since we deleted the entire @mention
// This ensures typing @ again will open the picker
options.setIgnoredAtPositions(new Set())
}
return
}
}

View File

@@ -1,9 +1,10 @@
import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker"
type PickerItem =
| { type: "agent"; agent: Agent }
@@ -37,7 +38,7 @@ type PromptPickerController = {
setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem) => void
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
handlePickerClose: () => void
}
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
setAtPosition(null)
}
function handlePickerSelect(item: PickerItem) {
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
const textarea = options.getTextarea()
if (item.type === "command") {
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
const name = item.command.name
const currentPrompt = options.prompt()
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
}
}, 0)
} else if (item.type === "agent") {
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
@@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
const suffix = opts?.trailingSpace ? " " : ""
const nextPrompt = before + mentionText + suffix + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
if (!nextTextarea) return
const nextCursorPos = pos + mentionText.length + suffix.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const replaceMentionQueryAfterAt = (value: string) => {
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const nextPrompt = before + value + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
const nextCursorPos = pos + 1 + value.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const folderMention =
relativePath === "." || relativePath === "" || relativePath === "./"
? "./"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "")
// If it's root "./", just return "./"
if (trimmed === "" || trimmed === ".") return "./"
// Otherwise remove any leading ./ and add ./ prefix
return "./" + trimmed.replace(/^\.\//, "")
})()
const addPathOnlyAttachment = (value: string) => {
const display = `path: ${value}`
const filename = value
const existing = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existing.some(
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
)
if (!alreadyAttached) {
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
}
}
if (isFolder) {
if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents.
replaceMentionQueryAfterAt(folderMention)
setSearchQuery(folderMention)
return
}
const mentionText = `@${folderMention}`
if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
replaceMentionToken(mentionText, { trailingSpace: true })
} else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedFolderPath,
dirFilename,
"inode/directory",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(mentionText, { trailingSpace: true })
}
} else {
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
if (action === "tab") {
// TAB on file: autocomplete the file path but do not attach.
replaceMentionToken(`@${normalizedPath}`)
setSearchQuery(normalizedPath)
return
}
if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
addPathOnlyAttachment(normalizedPathWithPrefix)
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
} else {
// ENTER/click on file: attach file (existing behavior).
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPathWithPrefix,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
}
}
}
setShowPicker(false)
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
}
setShowPicker(false)
setAtPosition(null)

View File

@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli"
import { preferences, setListeningMode } from "../stores/preferences"
import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
@@ -33,7 +34,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return
}
if (applyingListeningMode()) {
return
}
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return
}
setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
setApplyingListeningMode(true)
setError(null)
try {
// Important: await the config patch before restart so Electron reads the updated mode from disk.
await setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
}
void refreshMeta()
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked)
}}
disabled={loading() || applyingListeningMode()}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>

View File

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

View File

@@ -28,6 +28,8 @@ interface SessionViewProps {
instanceId: string
instanceFolder: string
escapeInDebounce: boolean
isPhoneLayout?: boolean
compactPromptLayout?: boolean
showSidebarToggle?: boolean
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
@@ -76,6 +78,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => {
if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
if (props.isPhoneLayout) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null
@@ -299,26 +304,34 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => {
if (promptInputApi) {
promptInputApi.removeAttachment(attachmentId)
return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
isActive={props.isActive}
compactLayout={props.compactPromptLayout}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
</div>
)
}}

View File

@@ -1,5 +1,6 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Copy } from "lucide-solid"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
@@ -27,7 +28,17 @@ import type {
ToolRendererContext,
ToolScrollHelpers,
} from "./tool-call/types"
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import {
ensureMarkdownContent,
getRelativePath,
getToolIcon,
getToolName,
isToolStateCompleted,
isToolStateError,
isToolStateRunning,
getDefaultToolAction,
readToolStatePayload,
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
@@ -155,12 +166,33 @@ export default function ToolCall(props: ToolCallProps) {
const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") {
const state = toolState()
if (state?.status === "error") {
return true
}
return false
}
return prefExpanded
})
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const toolInputsVisibility = createMemo(() => preferences().toolInputsVisibility || "collapsed")
const [toolInputVisibilityOverride, setToolInputVisibilityOverride] = createSignal<"hidden" | "expanded" | null>(null)
const effectiveToolInputsVisibility = createMemo(() => toolInputVisibilityOverride() ?? toolInputsVisibility())
const isToolInputVisible = createMemo(() => effectiveToolInputsVisibility() !== "hidden")
const inputDefaultExpanded = createMemo(() => effectiveToolInputsVisibility() === "expanded")
const [inputSectionOverride, setInputSectionOverride] = createSignal<boolean | null>(null)
const [outputSectionOverride, setOutputSectionOverride] = createSignal<boolean | null>(null)
const inputSectionExpanded = () => {
const override = inputSectionOverride()
if (override !== null) return override
return inputDefaultExpanded()
}
const outputSectionExpanded = () => {
const override = outputSectionOverride()
if (override !== null) return override
return true
}
const isPermissionActive = createMemo(() => {
const pending = pendingPermission()
@@ -183,6 +215,35 @@ export default function ToolCall(props: ToolCallProps) {
return defaultExpandedForTool()
}
const toolInput = createMemo(() => {
const state = toolState()
return readToolStatePayload(state).input
})
const hasToolInput = createMemo(() => {
const input = toolInput()
return input && Object.keys(input).length > 0
})
const toolInputMarkdown = createMemo(() => {
const input = toolInput()
if (!input || Object.keys(input).length === 0) return null
try {
const yamlText = stringifyYaml(input)
return ensureMarkdownContent(yamlText, "yaml", true)
} catch (error) {
log.error("Failed to convert tool call input to YAML", error)
try {
const jsonText = JSON.stringify(input, null, 2)
return ensureMarkdownContent(jsonText, "json", true)
} catch (nestedError) {
log.error("Failed to stringify tool call input", nestedError)
return null
}
}
})
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const questionDetails = createMemo(() => pendingQuestion()?.request)
@@ -515,13 +576,13 @@ export default function ToolCall(props: ToolCallProps) {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return "⏸"
return <Hourglass class="w-4 h-4" />
case "running":
return "⏳"
return <Loader2 class="w-4 h-4 animate-spin" />
case "completed":
return "✓"
return <Check class="w-4 h-4" />
case "error":
return "✗"
return <XCircle class="w-4 h-4" />
default:
return ""
}
@@ -548,6 +609,25 @@ export default function ToolCall(props: ToolCallProps) {
})
}
createEffect(() => {
// When global preference changes, reset per-tool-call overrides so palette changes apply.
toolInputsVisibility()
setToolInputVisibilityOverride(null)
setInputSectionOverride(null)
setOutputSectionOverride(null)
})
const handleToggleInputVisibility = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!expanded()) {
toggle()
}
const currentlyVisible = isToolInputVisible()
setToolInputVisibilityOverride(currentlyVisible ? "hidden" : "expanded")
}
const renderer = createMemo(() => resolveToolRenderer(toolName()))
const { renderAnsiContent } = createAnsiContentRenderer({
@@ -789,6 +869,23 @@ export default function ToolCall(props: ToolCallProps) {
</span>
</button>
<Show when={hasToolInput()}>
<button
type="button"
class="tool-call-header-input"
onClick={handleToggleInputVisibility}
aria-pressed={isToolInputVisible()}
aria-label={
isToolInputVisible()
? t("toolCall.header.hideInputAriaLabel")
: t("toolCall.header.showInputAriaLabel")
}
title={isToolInputVisible() ? t("toolCall.header.hideInputTitle") : t("toolCall.header.showInputTitle")}
>
<ArrowRightSquare class="w-3.5 h-3.5" />
</button>
</Show>
<button
type="button"
class="tool-call-header-copy"
@@ -806,19 +903,79 @@ export default function ToolCall(props: ToolCallProps) {
{expanded() && (
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
{renderPermissionBlock()}
{renderQuestionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
<Show
when={isToolInputVisible() && hasToolInput()}
fallback={
<>
{renderToolBody()}
{renderError()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</>
}
>
<div class="tool-call-io-sections">
<div class="tool-call-io-section">
<button
type="button"
class="tool-call-io-toggle"
aria-expanded={inputSectionExpanded()}
onClick={() => setInputSectionOverride((prev) => {
const current = prev === null ? inputSectionExpanded() : prev
return !current
})}
>
<span class="tool-call-io-title">{t("toolCall.io.input")}</span>
</button>
<Show when={inputSectionExpanded()}>
<div class="tool-call-io-body">
{(() => {
const content = toolInputMarkdown()
if (!content) return null
return renderMarkdownContent({ content, cacheKey: "input" })
})()}
</div>
</Show>
</div>
<div class="tool-call-io-section">
<button
type="button"
class="tool-call-io-toggle"
aria-expanded={outputSectionExpanded()}
onClick={() => setOutputSectionOverride((prev) => {
const current = prev === null ? outputSectionExpanded() : prev
return !current
})}
>
<span class="tool-call-io-title">{t("toolCall.io.output")}</span>
</button>
<Show when={outputSectionExpanded()}>
<div class="tool-call-io-body">
{renderToolBody()}
{renderError()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</div>
</Show>
</div>
</div>
</Show>
{renderPermissionBlock()}
{renderQuestionBlock()}
</div>
)}

View File

@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) {
return ""
}
if (trimmed === "." || trimmed === "./") {
return ""
}
// Don't normalize "." - it's used for workspace root
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
}
@@ -74,10 +72,12 @@ type PickerItem =
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
if (queryChanged) {
// Reset selectedIndex to 0 when query changes to avoid ghost state
// This ensures proper highlighting when navigating back to root or changing queries
setSelectedIndex(0)
resetScrollPosition()
}
if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true)
lastWorkspaceId = props.workspaceId
@@ -280,13 +287,14 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query
? props.agents.filter(
? visibleAgents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)),
)
: props.agents
: visibleAgents
setFilteredAgents(filtered)
})
@@ -341,7 +349,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
if (mode() === "mention" && isExactRootQuery) {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
items.push({ type: "file", file: rootFile })
}
// Don't show agents for exact root path queries
if (!isExactRootQuery) {
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
}
files().forEach((file) => items.push({ type: "file", file }))
return items
}
@@ -356,7 +379,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
function handleSelect(item: PickerItem) {
props.onSelect(item)
props.onSelect(item, "click")
}
function handleKeyDown(e: KeyboardEvent) {
@@ -379,7 +402,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
e.stopPropagation()
const selected = items[selectedIndex()]
if (selected) {
handleSelect(selected)
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action)
}
} else if (e.key === "Escape") {
e.preventDefault()
@@ -443,7 +467,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={isSelected()}
onClick={() => handleSelect({ type: "command", command })}
onClick={() => props.onSelect({ type: "command", command }, "click")}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -464,7 +488,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")}
</div>
@@ -479,7 +503,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "agent", agent })}
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
>
<div class="flex items-start gap-2">
<svg
@@ -519,10 +543,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && fileCount() > 0}>
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header">
{t("unifiedPicker.sections.files")}
</div>
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
<div
class={`dropdown-item py-1.5 ${
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={selectedIndex() === 0}
onClick={() => {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
props.onSelect({ type: "file", file: rootFile }, "click")
}}
>
<div class="flex items-center gap-2 text-sm">
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
</div>
</div>
</Show>
<For each={files()}>
{(file) => {
const itemIndex = allItems().findIndex(
@@ -535,7 +588,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "file", file })}
onClick={() => props.onSelect({ type: "file", file }, "click")}
>
<div class="flex items-center gap-2 text-sm">
<Show

View File

@@ -1,11 +1,7 @@
import type {
AppConfig,
BackgroundProcess,
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemCreateFolderResponse,
@@ -214,37 +210,27 @@ export const serverApi = {
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(updates),
body: JSON.stringify(patch ?? {}),
})
},
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
},
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(patch ?? {}),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})

View File

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

View File

@@ -1,6 +1,6 @@
import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
@@ -36,6 +38,7 @@ export interface UseCommandsOptions {
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
@@ -454,6 +457,26 @@ export function useCommands(options: UseCommandsOptions) {
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
@@ -529,6 +552,29 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {

View File

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

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
"commands.common.expanded": "Expanded",
"commands.common.collapsed": "Collapsed",
"commands.common.visible": "Visible",
@@ -124,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}",
"commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments",
"commands.toolInputsVisibility.keywords": "tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token Usage Display · {state}",
"commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats",
@@ -158,6 +168,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "COMMANDS",
"unifiedPicker.sections.agents": "AGENTS",
"unifiedPicker.sections.files": "FILES",
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
"unifiedPicker.badge.subagent": "subagent",
"unifiedPicker.footer.navigate": "navigate",
"unifiedPicker.footer.select": "select",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
"instanceShell.fullscreen.enter": "Full screen",
"instanceShell.fullscreen.exit": "Exit full screen",
"instanceShell.metrics.usedLabel": "Used",
"instanceShell.metrics.availableLabel": "Avail",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom",
"infoView.dispose.actions.dispose": "Dispose instance",
"infoView.dispose.actions.disposing": "Disposing...",
"infoView.dispose.confirm.title": "Dispose instance?",
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
"infoView.dispose.confirm.confirmLabel": "Dispose",
"infoView.dispose.confirm.cancelLabel": "Cancel",
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
"infoView.dispose.toast.error": "Failed to dispose instance.",
} as const

View File

@@ -21,6 +21,8 @@ export const sessionMessages = {
"sessionList.expand.expandAriaLabel": "Expand session",
"sessionList.expand.collapseTitle": "Collapse",
"sessionList.expand.expandTitle": "Expand",
"sessionList.actions.newSession.ariaLabel": "New session",
"sessionList.actions.newSession.title": "New session",
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
"sessionList.actions.copyId.title": "Copy session ID",
"sessionList.actions.rename.ariaLabel": "Rename session",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",

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.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
"releases.uiUpdated.title": "UI actualizada",
"releases.uiUpdated.message": "La UI ahora está actualizada a {version}.",
"releases.devUpdateAvailable.title": "Compilación dev disponible",
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
"releases.devUpdateAvailable.action": "Ver release",
} as const

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.keywords": "línea de tiempo, herramienta, alternar",
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
"commands.common.expanded": "Expandido",
"commands.common.collapsed": "Colapsado",
"commands.common.visible": "Visible",
@@ -124,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos",
"commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar",
"commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}",
"commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta",
"commands.toolInputsVisibility.keywords": "herramienta, entradas, argumentos, visibilidad, ocultar, mostrar, expandir, colapsar",
"commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}",
"commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente",
"commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
"instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho",
"instanceShell.fullscreen.enter": "Pantalla completa",
"instanceShell.fullscreen.exit": "Salir de pantalla completa",
"instanceShell.metrics.usedLabel": "Usado",
"instanceShell.metrics.availableLabel": "Disp.",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final",
"infoView.dispose.actions.dispose": "Desechar instancia",
"infoView.dispose.actions.disposing": "Desechando...",
"infoView.dispose.confirm.title": "¿Desechar instancia?",
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
"infoView.dispose.confirm.confirmLabel": "Desechar",
"infoView.dispose.confirm.cancelLabel": "Cancelar",
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
} as const

View File

@@ -21,6 +21,8 @@ export const sessionMessages = {
"sessionList.expand.expandAriaLabel": "Expandir sesión",
"sessionList.expand.collapseTitle": "Colapsar",
"sessionList.expand.expandTitle": "Expandir",
"sessionList.actions.newSession.ariaLabel": "Nueva sesión",
"sessionList.actions.newSession.title": "Nueva sesión",
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
"sessionList.actions.copyId.title": "Copiar ID de sesión",
"sessionList.actions.rename.ariaLabel": "Renombrar sesión",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",

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.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
"releases.uiUpdated.title": "UI mise à jour",
"releases.uiUpdated.message": "L'UI est maintenant mise à jour vers {version}.",
"releases.devUpdateAvailable.title": "Build dev disponible",
"releases.devUpdateAvailable.message": "Un nouveau build dev est disponible : {version}.",
"releases.devUpdateAvailable.action": "Voir la release",
} as const

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.keywords": "timeline, outil, basculer",
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
"commands.common.expanded": "Développé",
"commands.common.collapsed": "Réduit",
"commands.common.visible": "Visible",
@@ -124,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics",
"commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire",
"commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}",
"commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil",
"commands.toolInputsVisibility.keywords": "outil, entrées, arguments, visibilité, masquer, afficher, développer, réduire",
"commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}",
"commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant",
"commands.tokenUsageDisplay.keywords": "token, usage, coût, stats",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
"instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit",
"instanceShell.fullscreen.enter": "Plein écran",
"instanceShell.fullscreen.exit": "Quitter le plein écran",
"instanceShell.metrics.usedLabel": "Utilisé",
"instanceShell.metrics.availableLabel": "Dispo",

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