Compare commits

..

5 Commits

Author SHA1 Message Date
Shantur Rathore
2d1f702597 fix(ui): hide download action for local file attachments
Avoid showing a misleading download icon for file:// attachments in the message stream.
2026-02-14 10:52:15 +00:00
Shantur Rathore
2d93d82611 fix(ui): hide synthetic helper text in user messages
Avoid rendering tool trace/read output parts on user messages while keeping the primary prompt text visible.
2026-02-14 09:53:40 +00:00
Shantur Rathore
4e0f064c3a fix(ui): avoid stripping non-path @mentions 2026-02-14 08:18:35 +00:00
VooDisss
e4e10cc630 fix(ui): replace parent directory when selecting child directory 2026-02-14 07:39:39 +02:00
VooDisss
8f6d4c8b09 fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling 2026-02-14 06:53:31 +02:00
132 changed files with 1628 additions and 3721 deletions

View File

@@ -3,11 +3,6 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -50,8 +45,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -92,8 +85,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -133,8 +124,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -175,8 +164,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -250,8 +237,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -325,8 +310,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -405,8 +388,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -509,8 +490,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -608,8 +587,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,13 +1,12 @@
name: Develop Pre-Release name: Develop Pre-Release
on: on:
schedule: push:
# Nightly build of dev (only if dev has new commits) branches:
- cron: "0 1 * * *" - dev
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
id-token: write id-token: write
contents: write contents: write
@@ -16,63 +15,25 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
gate: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
run: ${{ steps.gate.outputs.run }} version_suffix: ${{ steps.vars.outputs.version_suffix }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps: steps:
- name: Decide whether to run - name: Compute version suffix
id: gate id: vars
shell: bash shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
SHA8="${GITHUB_SHA::8}"
api() {
curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$1"
}
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
echo "Failed to resolve dev head SHA" >&2
exit 1
fi
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
SHA8="${DEV_SHA::8}" echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
SHOULD_RUN="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
SHOULD_RUN="true"
else
# Nightly: only run if dev has advanced since last successful dev-release build.
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
if [ -z "${LAST_SHA}" ]; then
SHOULD_RUN="true"
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
SHOULD_RUN="true"
fi
fi
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
prerelease: prerelease:
needs: gate needs: prepare
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
ref: ${{ needs.gate.outputs.dev_sha }} version_suffix: ${{ needs.prepare.outputs.version_suffix }}
version_suffix: ${{ needs.gate.outputs.version_suffix }}
npm_package_name: "@neuralnomads/codenomad-dev" npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest dist_tag: latest
prerelease: true prerelease: true

View File

@@ -19,10 +19,6 @@ on:
type: string type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -50,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,13 +1,7 @@
name: Release UI name: Release UI
on: on:
workflow_call: workflow_call: {}
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@@ -24,8 +18,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -3,11 +3,6 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -51,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -91,7 +84,6 @@ jobs:
needs: prepare-release needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }} tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }} release_name: ${{ needs.prepare-release.outputs.release_name }}
@@ -103,8 +95,6 @@ jobs:
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
with:
ref: ${{ inputs.ref || github.ref }}
secrets: inherit secrets: inherit
publish-server: publish-server:
@@ -113,7 +103,6 @@ jobs:
- build-and-upload - build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }} package_name: ${{ inputs.npm_package_name }}

View File

@@ -44,22 +44,19 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
Full server/CLI documentation (flags + env vars, TLS, auth, remote access): For dev version
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad --help
```
### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash ```bash
npx @neuralnomads/codenomad-dev --launch npx @neuralnomads/codenomad-dev --launch
``` ```
Dev builds are published as GitHub pre-releases:
https://github.com/shantur/CodeNomad/releases
Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch.
This command starts the server and opens the web client in your default browser.
## Highlights ## Highlights
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs. - **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
@@ -123,6 +120,3 @@ To build the Desktop App from source:
1. Clone the repo. 1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces). 2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. 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)

23
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.4", "version": "0.10.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.4", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -2809,9 +2809,9 @@
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.2.6", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==", "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
@@ -11985,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.4", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.4", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.4", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12070,12 +12070,12 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.4", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -12092,8 +12092,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.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": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
return "local" return "local"
} }
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") { if (mode === "local" || mode === "all") {
return mode return mode
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.4", "version": "0.10.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

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

View File

@@ -5,21 +5,18 @@
## Features & Capabilities ## Features & Capabilities
### 🌍 Deployment Freedom ### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. - **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. - **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. - **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect. - **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power ### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches. - **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts. - **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites ## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system. - **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source). - Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve. - A workspace folder on disk you want to serve.
@@ -28,26 +25,18 @@
## Usage ## Usage
### Run via npx (Recommended) ### Run via npx (Recommended)
You can run CodeNomad directly without installing it: You can run CodeNomad directly without installing it:
```sh ```sh
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs: On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells) - `Local Connection URL : ...` (used by desktop shells)
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) - `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally ### Install Globally
Or install it globally to use the `codenomad` command: Or install it globally to use the `codenomad` command:
```sh ```sh
@@ -55,19 +44,7 @@ npm install -g @neuralnomads/codenomad
codenomad --launch codenomad --launch
``` ```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags ### Common Flags
You can configure the server using flags or environment variables: You can configure the server using flags or environment variables:
| Flag | Env Variable | Description | | Flag | Env Variable | Description |
@@ -81,36 +58,15 @@ You can configure the server using flags or environment variables:
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | | `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | | `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth | | `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh
npx @neuralnomads/codenomad-dev --launch
```
These environment variables control how CodeNomad checks for dev updates:
| Env Variable | Description |
|-------------|-------------|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
### HTTP vs HTTPS ### HTTP vs HTTPS
@@ -149,14 +105,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
``` ```
### Authentication ### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA) ### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
@@ -168,6 +122,5 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). > If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.4", "version": "0.10.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -1,6 +1,7 @@
import type { import type {
AgentModelSelection, AgentModelSelection,
AgentModelSelections, AgentModelSelections,
ConfigFile,
ModelPreference, ModelPreference,
OpenCodeBinary, OpenCodeBinary,
Preferences, Preferences,
@@ -182,9 +183,9 @@ export interface BinaryRecord {
validationError?: string validationError?: string
} }
export type SettingsOwner = string export type AppConfig = ConfigFile
export type SettingsBucket = Record<string, unknown> export type AppConfigResponse = AppConfig
export type SettingsDoc = Record<string, unknown> export type AppConfigUpdateRequest = Partial<AppConfig>
export interface BinaryListResponse { export interface BinaryListResponse {
binaries: BinaryRecord[] binaries: BinaryRecord[]
@@ -213,8 +214,8 @@ export type WorkspaceEventType =
| "workspace.error" | "workspace.error"
| "workspace.stopped" | "workspace.stopped"
| "workspace.log" | "workspace.log"
| "storage.configChanged" | "config.appChanged"
| "storage.stateChanged" | "config.binariesChanged"
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event" | "instance.event"
| "instance.eventStatus" | "instance.eventStatus"
@@ -225,8 +226,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "config.appChanged"; config: AppConfig }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }

View File

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

View File

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

View File

@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
import { createRequire } from "module" import { createRequire } from "module"
import { createHttpServer } from "./server/http-server" import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager" import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
import { resolveConfigLocation } from "./config/location" import { resolveConfigLocation } from "./config/location"
import { SettingsService } from "./settings/service" import { BinaryRegistry } from "./config/binaries"
import { BinaryResolver } from "./settings/binaries"
import { FileSystemBrowser } from "./filesystem/browser" import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types" import { ServerMeta } from "./api-types"
@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption( .addOption(
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()), new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
) )
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
@@ -291,12 +291,21 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const settings = new SettingsService(configLocation, eventBus, configLogger) const configStore = new ConfigStore(configLocation, eventBus, configLogger)
const binaryResolver = new BinaryResolver(settings)
// Eagerly load config at boot so migrations run immediately
// (instead of waiting for the first /api/config request).
try {
configStore.get()
} catch (error) {
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
}
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir, rootDir: options.rootDir,
settings, configStore,
binaryResolver, binaryRegistry,
eventBus, eventBus,
logger: workspaceLogger, logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.localUrl, getServerBaseUrl: () => serverMeta.localUrl,
@@ -383,7 +392,8 @@ async function main() {
defaultPort: options.httpPort, defaultPort: options.httpPort,
protocol: "http", protocol: "http",
workspaceManager, workspaceManager,
settings, configStore,
binaryRegistry,
fileSystemBrowser, fileSystemBrowser,
eventBus, eventBus,
serverMeta, serverMeta,
@@ -403,7 +413,8 @@ async function main() {
protocol: "https", protocol: "https",
httpsOptions: tlsResolution?.httpsOptions, httpsOptions: tlsResolution?.httpsOptions,
workspaceManager, workspaceManager,
settings, configStore,
binaryRegistry,
fileSystemBrowser, fileSystemBrowser,
eventBus, eventBus,
serverMeta, serverMeta,

View File

@@ -9,11 +9,12 @@ import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import type { SettingsService } from "../settings/service" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser" import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces" import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerSettingsRoutes } from "./routes/settings" import { registerConfigRoutes } from "./routes/config"
import { registerFilesystemRoutes } from "./routes/filesystem" import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta" import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events" import { registerEventRoutes } from "./routes/events"
@@ -36,7 +37,8 @@ interface HttpServerDeps {
protocol: "http" | "https" protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
settings: SettingsService configStore: ConfigStore
binaryRegistry: BinaryRegistry
fileSystemBrowser: FileSystemBrowser fileSystemBrowser: FileSystemBrowser
eventBus: EventBus eventBus: EventBus
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -242,7 +244,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}) })
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
@@ -367,21 +369,6 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1" 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: { async function proxyWorkspaceRequest(args: {
request: FastifyRequest request: FastifyRequest
reply: FastifyReply reply: FastifyReply
@@ -472,43 +459,19 @@ async function proxyWorkspaceRequest(args: {
return return
} }
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } const directory = await resolveWorktreeDirectory({
try { workspaceId,
extracted = extractOpencodeDirectoryOverride(args.pathSuffix) workspacePath: workspace.path,
} catch (error) { worktreeSlug,
const message = error instanceof Error ? error.message : "Invalid directory override" logger,
reply.code(400).send({ error: message }) })
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return return
} }
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
if (extracted.overrideDirectory) { const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
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 queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -572,89 +535,6 @@ 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) { function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") { if (!pathSuffix || pathSuffix === "/") {
return "/" return "/"

View File

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

View File

@@ -51,19 +51,7 @@ function getTokenHtml(): string {
} }
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { 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() const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username)) reply.type("text/html").send(getLoginHtml(status.username))
}) })
@@ -79,11 +67,6 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
return 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()) reply.type("text/html").send(getTokenHtml())
}) })

View File

@@ -0,0 +1,76 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
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, reply) => {
// Backwards compatible: treat PUT as a merge-patch update.
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.patch("/api/config/app", async (request, reply) => {
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.get("/api/config/binaries", async () => {
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

@@ -1,80 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,269 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,110 +0,0 @@
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,8 +2,8 @@ import path from "path"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { connect } from "net" import { connect } from "net"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import type { SettingsService } from "../settings/service" import { ConfigStore } from "../config/store"
import type { BinaryResolver } from "../settings/binaries" import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser" import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions { interface WorkspaceManagerOptions {
rootDir: string rootDir: string
settings: SettingsService configStore: ConfigStore
binaryResolver: BinaryResolver binaryRegistry: BinaryRegistry
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
getServerBaseUrl: () => string getServerBaseUrl: () => string
@@ -86,7 +86,7 @@ export class WorkspaceManager {
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> { async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`
const binary = this.options.binaryResolver.resolveDefault() const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath) clearWorkspaceSearchCache(workspacePath)
@@ -109,14 +109,17 @@ export class WorkspaceManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const serverConfig = this.options.settings.getOwner("config", "server") const preferences = this.options.configStore.get().preferences ?? {}
const envVars = (serverConfig as any)?.environmentVariables const userEnvironment = preferences.environmentVariables ?? {}
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword() const opencodePassword = generateOpencodeServerPassword()
@@ -145,10 +148,7 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
if (runtimeVersion) {
descriptor.binaryVersion = runtimeVersion
}
descriptor.pid = pid descriptor.pid = pid
descriptor.port = port descriptor.port = port
@@ -277,12 +277,42 @@ export class WorkspaceManager {
return candidates[0] ?? "" return candidates[0] ?? ""
} }
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
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")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: { private async waitForWorkspaceReadiness(params: {
workspaceId: string workspaceId: string
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
await Promise.race([ await Promise.race([
this.waitForPortAvailability(params.port), this.waitForPortAvailability(params.port),
@@ -296,7 +326,7 @@ export class WorkspaceManager {
}), }),
]) ])
const version = await this.waitForInstanceHealth(params) await this.waitForInstanceHealth(params)
await Promise.race([ await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS), this.delay(STARTUP_STABILITY_DELAY_MS),
@@ -309,8 +339,6 @@ export class WorkspaceManager {
) )
}), }),
]) ])
return version
} }
private async waitForInstanceHealth(params: { private async waitForInstanceHealth(params: {
@@ -318,7 +346,7 @@ export class WorkspaceManager {
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
const probeResult = await Promise.race([ const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port), this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => { params.exitPromise.then((info) => {
@@ -332,7 +360,7 @@ export class WorkspaceManager {
]) ])
if (probeResult.ok) { if (probeResult.ok) {
return probeResult.version return
} }
const latestOutput = params.getLastOutput().trim() const latestOutput = params.getLastOutput().trim()
@@ -343,11 +371,8 @@ export class WorkspaceManager {
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
} }
private async probeInstance( private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
workspaceId: string, const url = `http://127.0.0.1:${port}/project/current`
port: number,
): Promise<{ ok: boolean; reason?: string; version?: string }> {
const url = `http://127.0.0.1:${port}/global/health`
try { try {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -358,22 +383,11 @@ export class WorkspaceManager {
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `/global/health returned HTTP ${response.status}` const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason } return { ok: false, reason }
} }
return { ok: true }
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
const healthy = payload?.healthy === true
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
if (!healthy) {
const reason = "Instance reported unhealthy"
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
return { ok: false, reason }
}
return { ok: true, version: version || undefined }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error) const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")

View File

@@ -8,8 +8,6 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) 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[]) { export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
@@ -42,61 +40,6 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const } 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 const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> { function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

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

View File

@@ -140,16 +140,9 @@ struct PreferencesConfig {
listening_mode: Option<String>, listening_mode: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct ServerConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AppConfig { struct AppConfig {
preferences: Option<PreferencesConfig>, preferences: Option<PreferencesConfig>,
server: Option<ServerConfig>,
} }
fn resolve_config_locations() -> (PathBuf, PathBuf) { fn resolve_config_locations() -> (PathBuf, PathBuf) {
@@ -195,18 +188,11 @@ fn resolve_listening_mode() -> String {
if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(content) = fs::read_to_string(&yaml_path) {
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) { if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
let mode = config if let Some(mode) = config
.server .preferences
.as_ref() .as_ref()
.and_then(|srv| srv.listening_mode.as_ref()) .and_then(|prefs| prefs.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" { if mode == "local" {
return "local".to_string(); return "local".to_string();
} }
@@ -220,17 +206,11 @@ fn resolve_listening_mode() -> String {
// Legacy fallback. // Legacy fallback.
if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) { if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
let mode = config if let Some(mode) = config
.server .preferences
.as_ref() .as_ref()
.and_then(|srv| srv.listening_mode.as_ref()) .and_then(|prefs| prefs.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" { if mode == "local" {
return "local".to_string(); return "local".to_string();
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.4", "version": "0.10.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -30,8 +30,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.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": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,8 +1,6 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast" import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog" import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view" import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts" import { showConfirmDialog } from "./stores/alerts"
@@ -18,8 +16,6 @@ import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
@@ -62,10 +58,8 @@ const App: Component = () => {
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
serverSettings,
recordWorkspaceLaunch, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
@@ -74,116 +68,24 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) 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 = () => { const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance") const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0) 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(() => { createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
}) })
@@ -241,12 +143,41 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? "" const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) { if (!folderPath) {
return return
} }
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
recordWorkspaceLaunch(folderPath, selectedBinary) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
@@ -259,9 +190,13 @@ const App: Component = () => {
port: instances().get(instanceId)?.port, port: instances().get(instanceId)?.port,
}) })
} catch (error) { } catch (error) {
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message) const missingBinary = isMissingBinaryMessage(message)
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error) log.error("Failed to create instance", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
@@ -358,7 +293,6 @@ const App: Component = () => {
preferences, preferences,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
@@ -366,7 +300,6 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,
@@ -462,34 +395,19 @@ const App: Component = () => {
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}> <div class="h-screen w-screen flex flex-col">
<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 <Show
when={!hasInstances()} when={!hasInstances()}
fallback={ fallback={
<> <>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}> <InstanceTabs
<InstanceTabs instances={instances()}
instances={instances()} activeInstanceId={activeInstanceId()}
activeInstanceId={activeInstanceId()} onSelect={setActiveInstanceId}
onSelect={setActiveInstanceId} onClose={handleCloseInstance}
onClose={handleCloseInstance} onNew={handleNewInstanceRequest}
onNew={handleNewInstanceRequest} onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)} />
/>
</Show>
<For each={Array.from(instances().values())}> <For each={Array.from(instances().values())}>
{(instance) => { {(instance) => {
@@ -507,10 +425,7 @@ const App: Component = () => {
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} tabBarOffset={instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/> />
</InstanceMetadataProvider> </InstanceMetadataProvider>
@@ -536,17 +451,25 @@ const App: Component = () => {
<Show when={showFolderSelection()}> <Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"> <div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative"> <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 <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()} advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)} onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/> />
</div> </div>
</div> </div>

View File

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

View File

@@ -115,28 +115,28 @@ const AlertDialog: Component = () => {
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <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}> <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 items-start gap-3">
<div <div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold" class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{ style={{
"background-color": accent.badgeBg, "background-color": accent.badgeBg,
"border-color": accent.badgeBorder, "border-color": accent.badgeBorder,
color: accent.badgeText, color: accent.badgeText,
}} }}
aria-hidden aria-hidden
> >
{accent.symbol} {accent.symbol}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title> <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"> <Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message} {payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>} {payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description> </Dialog.Description>
</div> </div>
</div> </div>
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
) )
}} }}
</Show> </Show>
) )
} }
export default AlertDialog export default AlertDialog

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ interface HintRowProps {
const HintRow: Component<HintRowProps> = (props) => { const HintRow: Component<HintRowProps> = (props) => {
return ( return (
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}> <span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
{props.children} {props.children}
</span> </span>
) )

View File

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

View File

@@ -1,21 +1,14 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js" import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status" import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n" 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 { interface InstanceInfoProps {
instance: Instance instance: Instance
compact?: boolean compact?: boolean
showDisposeButton?: boolean
} }
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => { const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext() const metadataContext = useOptionalInstanceMetadataContext()
@@ -23,8 +16,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor() const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor() const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -34,46 +25,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : [] 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 ( return (
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -205,19 +156,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</div> </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>
</div> </div>
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode } from "../types"
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -18,10 +18,8 @@ interface ChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
listOpen: Accessor<boolean> listOpen: Accessor<boolean>
onToggleList: () => void onToggleList: () => void
@@ -79,6 +77,14 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <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"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null} when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
@@ -96,7 +102,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
@@ -177,17 +182,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
</div> </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 }} 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 DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode } from "../types"
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -29,10 +29,8 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
onRefresh: () => void onRefresh: () => void
@@ -82,6 +80,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <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"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={props.selectedLoading()} when={props.selectedLoading()}
@@ -116,7 +122,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
@@ -232,15 +237,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </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 }} list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -1,9 +1,8 @@
import { For, Show, type Accessor, type Component } from "solid-js" import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance" import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -207,25 +206,21 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
render: renderStatusSessionChanges, render: renderStatusSessionChanges,
}, },
{ {
id: "plan", id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan", labelKey: "instanceShell.rightPanel.sections.plan",
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
render: renderPlanSectionContent, render: renderPlanSectionContent,
}, },
{ {
id: "background-processes", id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
render: renderBackgroundProcesses, render: renderBackgroundProcesses,
}, },
{ {
id: "mcp", id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp", labelKey: "instanceShell.rightPanel.sections.mcp",
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -238,7 +233,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "lsp", id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp", labelKey: "instanceShell.rightPanel.sections.lsp",
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -251,7 +245,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "plugins", id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins", labelKey: "instanceShell.rightPanel.sections.plugins",
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -283,23 +276,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left"> <span>{props.t(section.labelKey)}</span>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />

View File

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

View File

@@ -23,7 +23,6 @@ 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_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_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_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) => export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

@@ -198,16 +198,6 @@ interface MessageContentItemProps {
onContentRendered?: () => void 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) { function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -232,9 +222,15 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx] const partId = ids[idx]
const part = current.parts[partId]?.data const part = current.parts[partId]?.data
if (!part) continue if (!part) continue
if (!isSupportedPartType(part)) continue if (
part.type === "tool" ||
if (!isContentPartType((part as any).type)) break part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
resolved.push(part) resolved.push(part)
} }
@@ -260,9 +256,15 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx] const partId = ids[idx]
const part = current.parts[partId]?.data const part = current.parts[partId]?.data
if (!part) continue if (!part) continue
if (!isSupportedPartType(part)) continue if (
part.type === "tool" ||
if (!isContentPartType((part as any).type)) continue part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (partHasRenderableText(part)) { if (partHasRenderableText(part)) {
return false return false
} }
@@ -547,9 +549,6 @@ export default function MessageBlock(props: MessageBlockProps) {
} }
orderedParts.forEach((part, partIndex) => { orderedParts.forEach((part, partIndex) => {
if (!isSupportedPartType(part)) {
return
}
if (part.type === "tool") { if (part.type === "tool") {
flushContent() flushContent()
const partId = part.id const partId = part.id

View File

@@ -162,8 +162,7 @@ export default function MessageItem(props: MessageItemProps) {
} }
const info = props.messageInfo const info = props.messageInfo
const timeInfo = info?.time as { created: number; end?: number } | undefined return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
} }
const handleRevert = () => { const handleRevert = () => {

View File

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

View File

@@ -3,6 +3,7 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -16,18 +17,16 @@ interface MessagePartProps {
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden. // Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null primaryUserTextPartId?: string | null
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || "" const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}` const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId()) const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant" const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") 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 shouldHideTextPart = () => {
const part = props.part const part = props.part
@@ -58,11 +57,6 @@ export default function MessagePart(props: MessagePartProps) {
return "" 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 { function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") { if (typeof segment === "string") {
return segment.trim().length > 0 return segment.trim().length > 0
@@ -97,28 +91,20 @@ export default function MessagePart(props: MessagePartProps) {
const createTextPartForMarkdown = (): TextPart => { const createTextPartForMarkdown = (): TextPart => {
const part = props.part const part = props.part
if (part.type === "text" && typeof part.text === "string") { if ((part.type === "text" || part.type === "reasoning") && 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 { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: (part as any).text, text: part.text,
synthetic: false, synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version, version: (part as { version?: number }).version
renderCache: (part as any).renderCache,
} }
} }
return { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: "", text: "",
synthetic: false, synthetic: false
} }
} }
@@ -131,18 +117,22 @@ export default function MessagePart(props: MessagePartProps) {
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}> <div class={textContainerClass()}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show
<Markdown when={isAssistantMessage()}
part={createTextPartForMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}
instanceId={props.instanceId} >
sessionId={props.sessionId} <Markdown
isDark={isDark()} part={createTextPartForMarkdown()}
size={isAssistantMessage() ? "tight" : "base"} instanceId={props.instanceId}
onRendered={props.onRendered} sessionId={props.sessionId}
/> isDark={isDark()}
</Show> size={isAssistantMessage() ? "tight" : "base"}
</div> onRendered={props.onRendered}
/>
</Show>
</div>
</Show> </Show>
</Match> </Match>

View File

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

View File

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

View File

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

View File

@@ -176,26 +176,15 @@ export default function PromptInput(props: PromptInputProps) {
), ),
) )
const isCoarsePointer = () => { onMount(() => {
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 handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null const activeElement = document.activeElement as HTMLElement
const isInputElement = const isInputElement =
activeElement?.tagName === "INPUT" || activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" || activeElement?.tagName === "SELECT" ||
Boolean(activeElement?.isContentEditable) activeElement?.isContentEditable
if (isInputElement) return if (isInputElement) return
@@ -203,25 +192,16 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return if (isModifierKey) return
const isSpecialKey = const isSpecialKey =
e.key === "Tab" || e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return if (isSpecialKey) return
const textarea = textareaRef if (e.key.length === 1 && textareaRef && !props.disabled) {
if (!textarea || textarea.disabled) return textareaRef.focus()
// 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) document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown) document.removeEventListener("keydown", handleGlobalKeyDown)
}) })
@@ -351,9 +331,7 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n") const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return if (!blockquote) return
// End the blockquote with a blank line so the user's next line insertBlockContent(`${blockquote}\n`)
// doesn't get parsed as a lazy continuation of the quote.
insertBlockContent(`${blockquote}\n\n`)
} }
function insertCodeSelection(rawText: string) { function insertCodeSelection(rawText: string) {
@@ -457,7 +435,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
disabled={props.disabled} disabled={props.disabled}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3} rows={expandState() === "expanded" ? 15 : 4}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autoCapitalize="off" autoCapitalize="off"
@@ -502,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</div> </div>
<Show when={shouldShowOverlay()}> <Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}> <div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show <Show
when={props.escapeInDebounce} when={props.escapeInDebounce}
fallback={ fallback={

View File

@@ -17,12 +17,6 @@ export interface PromptInputProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
sessionId: 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> onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void> onRunShell?: (command: string) => Promise<void>
disabled?: boolean disabled?: boolean

View File

@@ -1,8 +1,8 @@
import { createSignal, type Accessor, type Setter } from "solid-js" import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session" import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import type { PickerMode } from "./types" import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker" import type { PickerSelectAction } from "../unified-picker"
@@ -204,30 +204,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
} }
const folderMention = const folderMention =
relativePath === "." || relativePath === "" || relativePath === "./" relativePath === "." || relativePath === ""
? "./" ? "/"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/") : relativePath.replace(/\/+$/, "") + "/"
const normalizedFolderPath = (() => { const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "") const trimmed = relativePath.replace(/\/+$/, "")
// If it's root "./", just return "./" return trimmed.length > 0 ? trimmed : "."
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 (isFolder) {
if (action === "tab") { if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents. // TAB on directory: autocomplete directory name and show its contents.
@@ -239,14 +224,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const mentionText = `@${folderMention}` const mentionText = `@${folderMention}`
if (action === "shiftEnter") { if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending // SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
// Always prefix with ./ for consistency
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
replaceMentionToken(mentionText, { trailingSpace: true }) replaceMentionToken(mentionText, { trailingSpace: true })
} else { } else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL. // ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath const dirLabel =
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
@@ -255,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
) )
if (!alreadyAttached) { if (!alreadyAttached) {
// Remove any parent/child directory attachments that overlap with this one
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
for (const att of existingAttachments) {
if (
att.source.type === "file" &&
att.source.mime === "inode/directory" &&
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
) {
// Remove the overlapping directory attachment
removeAttachment(options.instanceId(), options.sessionId(), att.id)
}
}
const attachment = createFileAttachment( const attachment = createFileAttachment(
normalizedFolderPath, normalizedFolderPath,
dirFilename, dirFilename,
@@ -278,15 +275,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
} }
if (action === "shiftEnter") { if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending // SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
// Always prefix with ./ for consistency replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
addPathOnlyAttachment(normalizedPathWithPrefix)
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
} else { } else {
// ENTER/click on file: attach file (existing behavior). // ENTER/click on file: attach file (existing behavior).
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
const pathSegments = normalizedPath.split("/") const pathSegments = normalizedPath.split("/")
const filename = (() => { const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
@@ -295,12 +287,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some( const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix, (att) => att.source.type === "file" && att.source.path === normalizedPath,
) )
if (!alreadyAttached) { if (!alreadyAttached) {
const attachment = createFileAttachment( const attachment = createFileAttachment(
normalizedPathWithPrefix, normalizedPath,
filename, filename,
"text/plain", "text/plain",
undefined, undefined,
@@ -309,7 +301,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
addAttachment(options.instanceId(), options.sessionId(), attachment) addAttachment(options.instanceId(), options.sessionId(), attachment)
} }
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true }) replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
} }
} }
} }
@@ -342,9 +334,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
nextTextarea.setSelectionRange(pos, pos) nextTextarea.setSelectionRange(pos, pos)
} }
}, 0) }, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
} }
} }
setShowPicker(false) setShowPicker(false)

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

View File

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

View File

@@ -28,8 +28,6 @@ interface SessionViewProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
escapeInDebounce: boolean escapeInDebounce: boolean
isPhoneLayout?: boolean
compactPromptLayout?: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
@@ -78,9 +76,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => { (isActive) => {
if (!isActive) return 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.) // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null const activeEl = document.activeElement as HTMLElement | null
@@ -319,19 +314,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
</Show> </Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}
instanceFolder={props.instanceFolder} instanceFolder={props.instanceFolder}
sessionId={activeSession.id} sessionId={activeSession.id}
isActive={props.isActive} onSend={handleSendMessage}
compactLayout={props.compactPromptLayout} onRunShell={handleRunShell}
onSend={handleSendMessage} escapeInDebounce={props.escapeInDebounce}
onRunShell={handleRunShell} isSessionBusy={sessionBusy()}
escapeInDebounce={props.escapeInDebounce} disabled={sessionNeedsInput()}
isSessionBusy={sessionBusy()} onAbortSession={handleAbortSession}
disabled={sessionNeedsInput()} registerPromptInputApi={registerPromptInputApi}
onAbortSession={handleAbortSession} />
registerPromptInputApi={registerPromptInputApi}
/>
</div> </div>
) )
}} }}

View File

@@ -1,6 +1,5 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { Copy } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
@@ -28,17 +27,7 @@ import type {
ToolRendererContext, ToolRendererContext,
ToolScrollHelpers, ToolScrollHelpers,
} from "./tool-call/types" } from "./tool-call/types"
import { import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
ensureMarkdownContent,
getRelativePath,
getToolIcon,
getToolName,
isToolStateCompleted,
isToolStateError,
isToolStateRunning,
getDefaultToolAction,
readToolStatePayload,
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title" import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -166,33 +155,12 @@ export default function ToolCall(props: ToolCallProps) {
const prefExpanded = toolOutputDefaultExpanded() const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || "" const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") { if (toolName === "read") {
const state = toolState()
if (state?.status === "error") {
return true
}
return false return false
} }
return prefExpanded return prefExpanded
}) })
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null) 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 isPermissionActive = createMemo(() => {
const pending = pendingPermission() const pending = pendingPermission()
@@ -215,35 +183,6 @@ export default function ToolCall(props: ToolCallProps) {
return defaultExpandedForTool() 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 permissionDetails = createMemo(() => pendingPermission()?.permission)
const questionDetails = createMemo(() => pendingQuestion()?.request) const questionDetails = createMemo(() => pendingQuestion()?.request)
@@ -576,13 +515,13 @@ export default function ToolCall(props: ToolCallProps) {
const status = toolState()?.status || "" const status = toolState()?.status || ""
switch (status) { switch (status) {
case "pending": case "pending":
return <Hourglass class="w-4 h-4" /> return "⏸"
case "running": case "running":
return <Loader2 class="w-4 h-4 animate-spin" /> return "⏳"
case "completed": case "completed":
return <Check class="w-4 h-4" /> return "✓"
case "error": case "error":
return <XCircle class="w-4 h-4" /> return "✗"
default: default:
return "" return ""
} }
@@ -609,25 +548,6 @@ 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 renderer = createMemo(() => resolveToolRenderer(toolName()))
const { renderAnsiContent } = createAnsiContentRenderer({ const { renderAnsiContent } = createAnsiContentRenderer({
@@ -869,23 +789,6 @@ export default function ToolCall(props: ToolCallProps) {
</span> </span>
</button> </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 <button
type="button" type="button"
class="tool-call-header-copy" class="tool-call-header-copy"
@@ -903,79 +806,19 @@ export default function ToolCall(props: ToolCallProps) {
{expanded() && ( {expanded() && (
<div class="tool-call-details"> <div class="tool-call-details">
<Show {renderToolBody()}
when={isToolInputVisible() && hasToolInput()}
fallback={ {renderError()}
<>
{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()} {renderPermissionBlock()}
{renderQuestionBlock()} {renderQuestionBlock()}
<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> </div>
)} )}

View File

@@ -287,9 +287,7 @@ export const taskRenderer: ToolRenderer = {
content: promptContent()!, content: promptContent()!,
cacheKey: "task:prompt", cacheKey: "task:prompt",
disableScrollTracking: true, disableScrollTracking: true,
// Always use the normal markdown render path for prompt (even while running) disableHighlight: true,
// so the prompt doesn't visually change between running/completed states.
disableHighlight: false,
})} })}
</div> </div>
</section> </section>

View File

@@ -51,7 +51,9 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) { if (!trimmed) {
return "" return ""
} }
// Don't normalize "." - it's used for workspace root if (trimmed === "." || trimmed === "./") {
return ""
}
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
} }
@@ -287,14 +289,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase() const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query const filtered = query
? visibleAgents.filter( ? props.agents.filter(
(agent) => (agent) =>
agent.name.toLowerCase().includes(query) || agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)), (agent.description && agent.description.toLowerCase().includes(query)),
) )
: visibleAgents : props.agents
setFilteredAgents(filtered) setFilteredAgents(filtered)
}) })
@@ -349,22 +350,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items return items
} }
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/") filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
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 })) files().forEach((file) => items.push({ type: "file", file }))
return items return items
} }
@@ -488,7 +474,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}> <Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")} {t("unifiedPicker.sections.agents")}
</div> </div>
@@ -543,39 +529,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}> <Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.files")} {t("unifiedPicker.sections.files")}
</div> </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()}> <For each={files()}>
{(file) => { {(file) => {
const itemIndex = allItems().findIndex( const itemIndex = allItems().findIndex(

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,9 +39,6 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Open right drawer", "instanceShell.rightDrawer.toggle.open": "Open right drawer",
"instanceShell.rightDrawer.toggle.close": "Close 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.usedLabel": "Used",
"instanceShell.metrics.availableLabel": "Avail", "instanceShell.metrics.availableLabel": "Avail",
@@ -96,17 +93,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "The agent's roadmap for this session. Tracks tasks, subtasks, and their completion status.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Long-running processes started by the agent. You can monitor their output, stop, or terminate them.",
"instanceShell.rightPanel.sections.mcp": "MCP Servers", "instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol servers that extend the agent's capabilities with external tools and services.",
"instanceShell.rightPanel.sections.lsp": "LSP Servers", "instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocol servers providing code intelligence, diagnostics, and language-specific features.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins that customize the UI and server behavior, adding features beyond MCP and LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
"instanceShell.sessionChanges.loading": "Fetching session changes...", "instanceShell.sessionChanges.loading": "Fetching session changes...",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.", "infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...", "infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom", "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 } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode", "toolCall.diff.viewMode.ariaLabel": "Diff view mode",

View File

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

View File

@@ -39,16 +39,13 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho", "instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
"instanceShell.rightDrawer.toggle.close": "Cerrar 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.usedLabel": "Usado",
"instanceShell.metrics.availableLabel": "Disp.", "instanceShell.metrics.availableLabel": "Disp.",
"instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos", "instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos",
"instanceShell.commandPalette.button": "Paleta de comandos", "instanceShell.commandPalette.button": "Paleta de comandos",
"instanceShell.connection.ariaLabel": "Conexión {status}", "instanceShell.connection.ariaLabel": "Connection {status}",
"instanceShell.connection.connected": "Conectada", "instanceShell.connection.connected": "Conectada",
"instanceShell.connection.connecting": "Conectando...", "instanceShell.connection.connecting": "Conectando...",
"instanceShell.connection.disconnected": "Desconectada", "instanceShell.connection.disconnected": "Desconectada",
@@ -93,22 +90,16 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Hoja de ruta del agente para esta sesión. Realiza el seguimiento de tareas, subtareas y su estado de finalización.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Procesos de larga duración iniciados por el agente. Puedes supervisar su salida, detenerlos o terminarlos.",
"instanceShell.rightPanel.sections.mcp": "Servidores MCP", "instanceShell.rightPanel.sections.mcp": "Servidores MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Servidores del Model Context Protocol (MCP) que amplían las capacidades del agente con herramientas y servicios externos.",
"instanceShell.rightPanel.sections.lsp": "Servidores LSP", "instanceShell.rightPanel.sections.lsp": "Servidores LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Servidores del Language Server Protocol (LSP) que proporcionan inteligencia de código, diagnósticos y funciones específicas del lenguaje.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.", "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.",
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...", "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...",
"instanceShell.sessionChanges.empty": "Aún no hay cambios.", "instanceShell.sessionChanges.empty": "Aun no hay cambios.",
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.", "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.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final", "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 } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",

View File

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

View File

@@ -39,9 +39,6 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit", "instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
"instanceShell.rightDrawer.toggle.close": "Fermer 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.usedLabel": "Utilisé",
"instanceShell.metrics.availableLabel": "Dispo", "instanceShell.metrics.availableLabel": "Dispo",
@@ -94,17 +91,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Feuille de route de l'agent pour cette session. Suit les tâches et leur statut d'achèvement.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Processus longs démarrés par l'agent. Vous pouvez surveiller leur sortie, les arrêter ou les terminer.",
"instanceShell.rightPanel.sections.mcp": "Serveurs MCP", "instanceShell.rightPanel.sections.mcp": "Serveurs MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Serveurs du protocole Model Context Protocol qui étendent les capacités de l'agent avec des outils externes.",
"instanceShell.rightPanel.sections.lsp": "Serveurs LSP", "instanceShell.rightPanel.sections.lsp": "Serveurs LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Serveurs du protocole Language Server Protocol fournissant l'intelligence de code et les diagnostics.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.",
"instanceShell.sessionChanges.loading": "Récupération des changements...", "instanceShell.sessionChanges.loading": "Récupération des changements...",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.", "infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...", "infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
"infoView.logs.scrollToBottom": "Aller en bas", "infoView.logs.scrollToBottom": "Aller en bas",
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
"infoView.dispose.actions.disposing": "Réinitialisation...",
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
"infoView.dispose.confirm.cancelLabel": "Annuler",
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
} as const } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",

View File

@@ -97,12 +97,6 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え", "commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle", "commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
"commands.common.expanded": "展開", "commands.common.expanded": "展開",
"commands.common.collapsed": "折りたたみ", "commands.common.collapsed": "折りたたみ",
"commands.common.visible": "表示", "commands.common.visible": "表示",
@@ -130,10 +124,6 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え", "commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え",
"commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse", "commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "ツール入力の表示 · {state}",
"commands.toolInputsVisibility.description": "ツール呼び出しの入力引数の既定の表示状態を設定します",
"commands.toolInputsVisibility.keywords": "ツール, 入力, 引数, 表示, 非表示, 展開, 折りたたみ, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}", "commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}",
"commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示", "commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示",
"commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats", "commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats",

View File

@@ -39,9 +39,6 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く", "instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる", "instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
"instanceShell.fullscreen.enter": "全画面",
"instanceShell.fullscreen.exit": "全画面を終了",
"instanceShell.metrics.usedLabel": "使用", "instanceShell.metrics.usedLabel": "使用",
"instanceShell.metrics.availableLabel": "残り", "instanceShell.metrics.availableLabel": "残り",
@@ -94,17 +91,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan": "計画",
"instanceShell.rightPanel.sections.plan.tooltip": "このセッションにおけるエージェントのロードマップ。タスクやサブタスク、および完了状況を追跡します。",
"instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "エージェントが開始した長時間実行プロセス。出力を監視し、停止または終了できます。",
"instanceShell.rightPanel.sections.mcp": "MCP サーバー", "instanceShell.rightPanel.sections.mcp": "MCP サーバー",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol (MCP) サーバー。外部ツールやサービスでエージェントの機能を拡張します。",
"instanceShell.rightPanel.sections.lsp": "LSP サーバー", "instanceShell.rightPanel.sections.lsp": "LSP サーバー",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocolサーバーがコードインテリジェンス、診断、言語固有の機能を提供します。",
"instanceShell.rightPanel.sections.plugins": "プラグイン", "instanceShell.rightPanel.sections.plugins": "プラグイン",
"instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。",
"instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。",
"instanceShell.sessionChanges.loading": "変更を取得中...", "instanceShell.sessionChanges.loading": "変更を取得中...",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。", "infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
"infoView.logs.empty.waiting": "サーバー出力を待機中...", "infoView.logs.empty.waiting": "サーバー出力を待機中...",
"infoView.logs.scrollToBottom": "最下部へスクロール", "infoView.logs.scrollToBottom": "最下部へスクロール",
"infoView.dispose.actions.dispose": "インスタンスを破棄",
"infoView.dispose.actions.disposing": "破棄しています...",
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
"infoView.dispose.confirm.confirmLabel": "破棄",
"infoView.dispose.confirm.cancelLabel": "キャンセル",
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
} as const } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード", "toolCall.diff.viewMode.ariaLabel": "diff 表示モード",

View File

@@ -97,12 +97,6 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений", "commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить", "commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
"commands.common.expanded": "Развернуто", "commands.common.expanded": "Развернуто",
"commands.common.collapsed": "Свернуто", "commands.common.collapsed": "Свернуто",
"commands.common.visible": "Видимо", "commands.common.visible": "Видимо",
@@ -130,10 +124,6 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию", "commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию",
"commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть", "commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть",
"commands.toolInputsVisibility.label": "Видимость входных данных инструмента · {state}",
"commands.toolInputsVisibility.description": "Установить видимость аргументов входа вызовов инструментов по умолчанию",
"commands.toolInputsVisibility.keywords": "инструмент, вход, аргументы, видимость, скрыть, показать, раскрыть, свернуть, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}", "commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}",
"commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента", "commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика", "commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика",

View File

@@ -39,9 +39,6 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель", "instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель", "instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
"instanceShell.fullscreen.enter": "Полный экран",
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
"instanceShell.metrics.usedLabel": "Использовано", "instanceShell.metrics.usedLabel": "Использовано",
"instanceShell.metrics.availableLabel": "Доступно", "instanceShell.metrics.availableLabel": "Доступно",
@@ -94,17 +91,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan": "План",
"instanceShell.rightPanel.sections.plan.tooltip": "Дорожная карта агента для этой сессии. Отслеживает задачи и их статус выполнения.", "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell",
"instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые оболочки",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Долгоработающие процессы, запущенные агентом. Вы можете следить за их выводом, останавливать или завершать их.",
"instanceShell.rightPanel.sections.mcp": "MCP-серверы", "instanceShell.rightPanel.sections.mcp": "MCP-серверы",
"instanceShell.rightPanel.sections.mcp.tooltip": "Серверы протокола Model Context Protocol, расширяющие возможности агента внешними инструментами.",
"instanceShell.rightPanel.sections.lsp": "LSP-серверы", "instanceShell.rightPanel.sections.lsp": "LSP-серверы",
"instanceShell.rightPanel.sections.lsp.tooltip": "Серверы протокола Language Server Protocol, обеспечивающие интеллектуальную поддержку кода и диагностику.",
"instanceShell.rightPanel.sections.plugins": "Плагины", "instanceShell.rightPanel.sections.plugins": "Плагины",
"instanceShell.rightPanel.sections.plugins.tooltip": "Плагины, настраивающие поведение интерфейса и сервера, добавляющие функции поверх MCP и LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.",
"instanceShell.sessionChanges.loading": "Загрузка изменений...", "instanceShell.sessionChanges.loading": "Загрузка изменений...",
@@ -134,7 +125,7 @@ export const instanceMessages = {
"versionPill.uiWithVersion": "UI {version}", "versionPill.uiWithVersion": "UI {version}",
"versionPill.source": " ({source})", "versionPill.source": " ({source})",
"opencodeBinarySelector.title": "Бинарник OpenCode", "opencodeBinarySelector.title": "OpenCode Binary",
"opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать", "opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать",
"opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…", "opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…",
"opencodeBinarySelector.actions.add": "Добавить", "opencodeBinarySelector.actions.add": "Добавить",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.", "infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
"infoView.logs.empty.waiting": "Ожидание вывода сервера…", "infoView.logs.empty.waiting": "Ожидание вывода сервера…",
"infoView.logs.scrollToBottom": "Прокрутить вниз", "infoView.logs.scrollToBottom": "Прокрутить вниз",
"infoView.dispose.actions.dispose": "Сбросить инстанс",
"infoView.dispose.actions.disposing": "Сброс...",
"infoView.dispose.confirm.title": "Сбросить инстанс?",
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
"infoView.dispose.confirm.confirmLabel": "Сбросить",
"infoView.dispose.confirm.cancelLabel": "Отмена",
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
} as const } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",

View File

@@ -97,12 +97,6 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目", "commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换", "commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
"commands.common.expanded": "展开", "commands.common.expanded": "展开",
"commands.common.collapsed": "折叠", "commands.common.collapsed": "折叠",
"commands.common.visible": "可见", "commands.common.visible": "可见",
@@ -130,10 +124,6 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "切换诊断输出是否默认展开", "commands.diagnosticsDefault.description": "切换诊断输出是否默认展开",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠",
"commands.toolInputsVisibility.label": "工具输入可见性 · {state}",
"commands.toolInputsVisibility.description": "设置工具调用输入参数的默认可见性",
"commands.toolInputsVisibility.keywords": "工具, 输入, 参数, 可见性, 隐藏, 显示, 展开, 折叠, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token 使用显示 · {state}", "commands.tokenUsageDisplay.label": "Token 使用显示 · {state}",
"commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计", "commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计",

View File

@@ -39,9 +39,6 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉", "instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉", "instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
"instanceShell.fullscreen.enter": "全屏",
"instanceShell.fullscreen.exit": "退出全屏",
"instanceShell.metrics.usedLabel": "已用", "instanceShell.metrics.usedLabel": "已用",
"instanceShell.metrics.availableLabel": "可用", "instanceShell.metrics.availableLabel": "可用",
@@ -94,17 +91,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan": "计划",
"instanceShell.rightPanel.sections.plan.tooltip": "代理的路线图。跟踪任务、子任务及其完成状态。",
"instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "代理启动的后台进程。您可以监控其输出、停止或终止它们。",
"instanceShell.rightPanel.sections.mcp": "MCP 服务器", "instanceShell.rightPanel.sections.mcp": "MCP 服务器",
"instanceShell.rightPanel.sections.mcp.tooltip": "模型上下文协议服务器,使用外部工具和服务扩展代理能力。",
"instanceShell.rightPanel.sections.lsp": "LSP 服务器", "instanceShell.rightPanel.sections.lsp": "LSP 服务器",
"instanceShell.rightPanel.sections.lsp.tooltip": "语言服务器协议服务器,提供代码智能、诊断和语言特定的功能。",
"instanceShell.rightPanel.sections.plugins": "插件", "instanceShell.rightPanel.sections.plugins": "插件",
"instanceShell.rightPanel.sections.plugins.tooltip": "自定义 UI 和服务器行为的插件,添加超出 MCP 和 LSP 的功能。",
"instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。",
"instanceShell.sessionChanges.loading": "正在获取会话更改...", "instanceShell.sessionChanges.loading": "正在获取会话更改...",

View File

@@ -15,13 +15,4 @@ export const logMessages = {
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。", "infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
"infoView.logs.empty.waiting": "正在等待服务器输出...", "infoView.logs.empty.waiting": "正在等待服务器输出...",
"infoView.logs.scrollToBottom": "滚动到底部", "infoView.logs.scrollToBottom": "滚动到底部",
"infoView.dispose.actions.dispose": "释放实例",
"infoView.dispose.actions.disposing": "正在释放...",
"infoView.dispose.confirm.title": "要释放实例吗?",
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
"infoView.dispose.confirm.confirmLabel": "释放",
"infoView.dispose.confirm.cancelLabel": "取消",
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
"infoView.dispose.toast.error": "释放实例失败。",
} as const } as const

View File

@@ -5,14 +5,6 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "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": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",

View File

@@ -1,29 +0,0 @@
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
if (!error) {
return fallbackMessage
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
return (parsed as any).error
}
} catch {
// ignore JSON parse errors
}
return raw
}
export function isMissingBinaryMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}

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