diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 47870b3b..8ddc9daa 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -3,6 +3,11 @@ name: Build and Upload Binaries on: workflow_call: inputs: + ref: + description: "Git ref (branch, tag, or SHA) to build from" + required: false + default: "" + type: string version: description: "Version to apply to workspace packages (release builds)" required: false @@ -45,6 +50,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -85,6 +92,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -124,6 +133,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -164,6 +175,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -237,6 +250,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -310,6 +325,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -388,6 +405,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -490,6 +509,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup QEMU uses: docker/setup-qemu-action@v3 @@ -587,6 +608,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 4fdc3c0f..22479267 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -1,12 +1,13 @@ name: Develop Pre-Release on: - push: - branches: - - dev + schedule: + # Nightly build of dev (only if dev has new commits) + - cron: "0 1 * * *" workflow_dispatch: permissions: + actions: read id-token: write contents: write @@ -15,25 +16,63 @@ concurrency: cancel-in-progress: true jobs: - prepare: + gate: runs-on: ubuntu-latest outputs: - version_suffix: ${{ steps.vars.outputs.version_suffix }} + run: ${{ steps.gate.outputs.run }} + dev_sha: ${{ steps.gate.outputs.dev_sha }} + version_suffix: ${{ steps.gate.outputs.version_suffix }} steps: - - name: Compute version suffix - id: vars + - name: Decide whether to run + id: gate shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | 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) - echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT" + SHA8="${DEV_SHA::8}" + 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: - needs: prepare + needs: gate + if: ${{ needs.gate.outputs.run == 'true' }} uses: ./.github/workflows/reusable-release.yml with: - version_suffix: ${{ needs.prepare.outputs.version_suffix }} + ref: ${{ needs.gate.outputs.dev_sha }} + version_suffix: ${{ needs.gate.outputs.version_suffix }} npm_package_name: "@neuralnomads/codenomad-dev" dist_tag: latest prerelease: true diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 81d93fd1..51f5a328 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -19,6 +19,10 @@ on: type: string workflow_call: inputs: + ref: + required: false + default: "" + type: string version: required: true type: string @@ -46,6 +50,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml index 57195dca..2a021832 100644 --- a/.github/workflows/release-ui.yml +++ b/.github/workflows/release-ui.yml @@ -1,7 +1,13 @@ name: Release UI on: - workflow_call: {} + workflow_call: + inputs: + ref: + description: "Git ref (branch, tag, or SHA) to build from" + required: false + default: "" + type: string workflow_dispatch: {} permissions: @@ -18,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index c34959ba..e839e76b 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -3,6 +3,11 @@ name: Reusable Release on: workflow_call: inputs: + ref: + description: "Git ref (branch, tag, or SHA) to build from" + required: false + default: "" + type: string version_suffix: description: "Suffix appended to package.json version" required: false @@ -46,6 +51,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup Node uses: actions/setup-node@v4 @@ -84,6 +91,7 @@ jobs: needs: prepare-release uses: ./.github/workflows/build-and-upload.yml with: + ref: ${{ inputs.ref || github.ref }} version: ${{ needs.prepare-release.outputs.version }} tag: ${{ needs.prepare-release.outputs.tag }} release_name: ${{ needs.prepare-release.outputs.release_name }} @@ -95,6 +103,8 @@ jobs: permissions: contents: read uses: ./.github/workflows/release-ui.yml + with: + ref: ${{ inputs.ref || github.ref }} secrets: inherit publish-server: @@ -103,6 +113,7 @@ jobs: - build-and-upload uses: ./.github/workflows/manual-npm-publish.yml with: + ref: ${{ inputs.ref || github.ref }} version: ${{ needs.prepare-release.outputs.version }} dist_tag: ${{ inputs.dist_tag }} package_name: ${{ inputs.npm_package_name }} diff --git a/README.md b/README.md index eae634f3..ea82aba7 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,6 @@ To build the Desktop App from source: 1. Clone the repo. 2. Run `npm install` (requires pnpm or npm 7+ for workspaces). 3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. + +[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) + diff --git a/package-lock.json b/package-lock.json index 5c62f29f..99303498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.4", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -2809,9 +2809,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", - "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", + "integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==", "license": "MIT" }, "node_modules/@pinojs/redact": { @@ -11985,7 +11985,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.1", + "version": "0.11.4", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12021,7 +12021,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.4", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12062,7 +12062,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.11.1", + "version": "0.11.4", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12070,12 +12070,12 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.11.1", + "version": "0.11.4", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.11", + "@opencode-ai/sdk": "1.2.6", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -12092,7 +12092,8 @@ "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0", - "tauri-plugin-keepawake-api": "^0.1.0" + "tauri-plugin-keepawake-api": "^0.1.0", + "yaml": "^2.4.2" }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", diff --git a/package.json b/package.json index 412c0278..e2381e85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.4", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 092e3692..b1918510 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.1", + "version": "0.11.4", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index efa583b3..4c3ce075 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.4" + "@opencode-ai/plugin": "1.2.10" } -} +} \ No newline at end of file diff --git a/packages/server/README.md b/packages/server/README.md index cb798eb1..131d48d3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -5,18 +5,21 @@ ## Features & Capabilities ### 🌍 Deployment Freedom + - **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. - **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. ### ⚡️ Workspace Power + - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Long-Context Native**: Scroll through massive transcripts without hitches. - **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. ## Prerequisites + - **OpenCode**: `opencode` must be installed and configured on your system. - Node.js 18+ and npm (for running or building from source). - A workspace folder on disk you want to serve. @@ -25,6 +28,7 @@ ## Usage ### Run via npx (Recommended) + You can run CodeNomad directly without installing it: ```sh @@ -43,6 +47,7 @@ On startup, CodeNomad prints two URLs: - `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) ### Install Globally + Or install it globally to use the `codenomad` command: ```sh @@ -51,6 +56,7 @@ codenomad --launch ``` ### Install Locally (per-project) + If you prefer to install CodeNomad into a project and run the local binary: ```sh @@ -61,6 +67,7 @@ npx codenomad --launch (`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.) ### Common Flags + You can configure the server using flags or environment variables: | Flag | Env Variable | Description | @@ -74,7 +81,7 @@ You can configure the server using flags or environment variables: | `--tls-ca ` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tlsSANs ` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--host ` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | -| `--workspace-root ` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces | +| `--workspace-root ` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--config ` | `CLI_CONFIG` | Config file location | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | @@ -87,10 +94,11 @@ You can configure the server using flags or environment variables: | `--ui-dir ` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dev-server ` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | -| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) | +| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | ### Dev Releases (Advanced) + If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: ```sh @@ -141,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10" ``` ### Authentication + - 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. 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. ### 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. 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). @@ -158,5 +168,6 @@ 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). ### Data Storage + - **Config**: `~/.config/codenomad/config.json` - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 038a7e79..1755c004 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.4", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index dd45c0a8..02082c24 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.4", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 74f0f0d6..a317a5d3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions { .addOption(new Option("--tls-ca ", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tlsSANs ", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption( - new Option("--workspace-root ", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()), + new Option("--workspace-root ", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()), ) .addOption(new Option("--root ").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3fc7106e..dd36f882 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe const INSTANCE_PROXY_HOST = "127.0.0.1" +// Special-case OpenCode directory override. +// +// UI clients may need to scope certain requests to an arbitrary directory that is not +// part of the Git worktree list. Since the OpenCode SDK does not reliably support +// injecting per-request headers, we encode an override into the *path* and strip it +// before proxying to the instance. +// +// Example proxied request path: +// /workspaces/:id/worktrees/:slug/instance/__dir//session/create +// +// The server will decode -> absolute directory, validate it, then set +// x-opencode-directory accordingly and forward the request to /session/create. +const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/" +const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096 + async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply @@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: { return } - const directory = await resolveWorktreeDirectory({ - workspaceId, - workspacePath: workspace.path, - worktreeSlug, - logger, - }) - - if (!directory) { - reply.code(404).send({ error: "Worktree not found" }) + let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } + try { + extracted = extractOpencodeDirectoryOverride(args.pathSuffix) + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid directory override" + reply.code(400).send({ error: message }) return } + let directory: string | null = null + let forwardedSuffix = extracted.forwardedSuffix - const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) + if (extracted.overrideDirectory) { + try { + directory = validateAndNormalizeOverrideDirectory({ + overrideDirectory: extracted.overrideDirectory, + workspaceRoot: workspace.path, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid directory override" + reply.code(400).send({ error: message }) + return + } + } else { + directory = await resolveWorktreeDirectory({ + workspaceId, + workspacePath: workspace.path, + worktreeSlug, + logger, + }) + + if (!directory) { + reply.code(404).send({ error: "Worktree not found" }) + return + } + } + + const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` @@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: { }) } +function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): { + overrideDirectory: string | null + forwardedSuffix: string | undefined +} { + if (!pathSuffix) { + return { overrideDirectory: null, forwardedSuffix: pathSuffix } + } + + // Fastify wildcard param does not include a leading slash. + const trimmed = pathSuffix.replace(/^\/+/, "") + if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) { + return { overrideDirectory: null, forwardedSuffix: pathSuffix } + } + + const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length) + const slashIndex = rest.indexOf("/") + const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim() + const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : "" + + if (!encoded) { + throw new Error("Missing directory override") + } + + if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) { + throw new Error("Directory override too large") + } + + let overrideDirectory = "" + try { + overrideDirectory = decodeBase64Url(encoded) + } catch { + throw new Error("Invalid directory override") + } + const forwardedSuffix = remaining + return { overrideDirectory, forwardedSuffix } +} + +function decodeBase64Url(input: string): string { + // base64url -> base64 + const normalized = input.replace(/-/g, "+").replace(/_/g, "/") + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) + const base64 = `${normalized}${padding}` + return Buffer.from(base64, "base64").toString("utf-8") +} + +function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string { + const raw = params.overrideDirectory.trim() + if (!raw) { + throw new Error("Override directory is empty") + } + + if (!path.isAbsolute(raw)) { + throw new Error("Override directory must be an absolute path") + } + + if (!fs.existsSync(raw)) { + throw new Error(`Override directory does not exist: ${raw}`) + } + + const stats = fs.statSync(raw) + if (!stats.isDirectory()) { + throw new Error(`Override path is not a directory: ${raw}`) + } + + const normalizedOverride = fs.realpathSync(raw) + const normalizedRoot = fs.realpathSync(params.workspaceRoot) + + if (!isSubpath(normalizedOverride, normalizedRoot)) { + throw new Error("Override directory must be within the workspace root") + } + + return normalizedOverride +} + +function isSubpath(candidate: string, root: string): boolean { + const rel = path.relative(root, candidate) + if (rel === "") return true + if (rel === "..") return false + if (rel.startsWith(`..${path.sep}`)) return false + if (path.isAbsolute(rel)) return false + return true +} + function normalizeInstanceSuffix(pathSuffix: string | undefined) { if (!pathSuffix || pathSuffix === "/") { return "/" diff --git a/packages/server/src/server/routes/auth-pages/login.html b/packages/server/src/server/routes/auth-pages/login.html index c3f95ac8..5dea9c70 100644 --- a/packages/server/src/server/routes/auth-pages/login.html +++ b/packages/server/src/server/routes/auth-pages/login.html @@ -119,7 +119,8 @@ showError(message || `Login failed (${res.status})`) return } - window.location.href = "/" + // Replace history entry so Back doesn't return to /login. + window.location.replace("/") } catch (e) { showError(e && e.message ? e.message : String(e)) } diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts index e47da74a..6bb7d3d3 100644 --- a/packages/server/src/server/routes/auth.ts +++ b/packages/server/src/server/routes/auth.ts @@ -51,7 +51,19 @@ function getTokenHtml(): string { } export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/login", async (_request, reply) => { + app.get("/login", async (request, reply) => { + // If already authenticated, don't show the login page. + const session = deps.authManager.getSessionFromRequest(request) + if (session) { + reply.redirect("/") + return + } + + // Avoid caching the login page (helps with bfcache/back behavior). + reply.header("Cache-Control", "no-store") + reply.header("Pragma", "no-cache") + reply.header("Expires", "0") + const status = deps.authManager.getStatus() reply.type("text/html").send(getLoginHtml(status.username)) }) @@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { return } + // Avoid caching the token bootstrap page. + reply.header("Cache-Control", "no-store") + reply.header("Pragma", "no-cache") + reply.header("Expires", "0") + reply.type("text/html").send(getTokenHtml()) }) diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 4f5a70eb..e5673275 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" -import { spawnSync } from "child_process" -import { buildSpawnSpec } from "../../workspaces/runtime" +import { probeBinaryVersion } from "../../workspaces/runtime" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" @@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({ }) function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { - if (!binaryPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(binaryPath, ["--version"]) - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } + const result = probeBinaryVersion(binaryPath) + return { valid: result.valid, version: result.version, error: result.error } } export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a4b50e06..602589ee 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -109,10 +109,6 @@ export class WorkspaceManager { updatedAt: new Date().toISOString(), } - if (!descriptor.binaryVersion) { - descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath) - } - this.workspaces.set(id, descriptor) @@ -149,7 +145,10 @@ export class WorkspaceManager { onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) - await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + if (runtimeVersion) { + descriptor.binaryVersion = runtimeVersion + } descriptor.pid = pid descriptor.port = port @@ -278,42 +277,12 @@ export class WorkspaceManager { 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: { workspaceId: string port: number exitPromise: Promise getLastOutput: () => string - }) { + }): Promise { await Promise.race([ this.waitForPortAvailability(params.port), @@ -327,7 +296,7 @@ export class WorkspaceManager { }), ]) - await this.waitForInstanceHealth(params) + const version = await this.waitForInstanceHealth(params) await Promise.race([ this.delay(STARTUP_STABILITY_DELAY_MS), @@ -340,6 +309,8 @@ export class WorkspaceManager { ) }), ]) + + return version } private async waitForInstanceHealth(params: { @@ -347,7 +318,7 @@ export class WorkspaceManager { port: number exitPromise: Promise getLastOutput: () => string - }) { + }): Promise { const probeResult = await Promise.race([ this.probeInstance(params.workspaceId, params.port), params.exitPromise.then((info) => { @@ -361,7 +332,7 @@ export class WorkspaceManager { ]) if (probeResult.ok) { - return + return probeResult.version } const latestOutput = params.getLastOutput().trim() @@ -372,8 +343,11 @@ export class WorkspaceManager { throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) } - private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> { - const url = `http://127.0.0.1:${port}/project/current` + private async probeInstance( + workspaceId: string, + port: number, + ): Promise<{ ok: boolean; reason?: string; version?: string }> { + const url = `http://127.0.0.1:${port}/global/health` try { const headers: Record = {} @@ -384,11 +358,22 @@ export class WorkspaceManager { const response = await fetch(url, { headers }) if (!response.ok) { - const reason = `health probe returned HTTP ${response.status}` + const reason = `/global/health returned HTTP ${response.status}` this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") 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) { const reason = error instanceof Error ? error.message : String(error) this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index a7196d60..0246fbfd 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -8,6 +8,8 @@ import { Logger } from "../logger" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) +const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ + export function buildSpawnSpec(binaryPath: string, args: string[]) { if (process.platform !== "win32") { return { command: binaryPath, args, options: {} as const } @@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) { return { command: binaryPath, args, options: {} as const } } +export function probeBinaryVersion(binaryPath: string): { + valid: boolean + version?: string + reported?: string + error?: string +} { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean( + (spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments, + ), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdoutLines = String(result.stdout ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const stderrLines = String(result.stderr ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + // Prefer stdout; fall back to stderr (some tools report version there). + const reported = stdoutLines[0] ?? stderrLines[0] + if (!reported) { + return { valid: true } + } + + const versionMatch = reported.match(VERSION_REGEX) + const version = versionMatch?.[1] + return { valid: true, version, reported } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i function redactEnvironment(env: Record): Record { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index adb8357f..b86075f5 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.11.1", + "version": "0.11.4", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 7a75280f..50ab2cdd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.11.1", + "version": "0.11.4", "private": true, "license": "MIT", "type": "module", @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.11", + "@opencode-ai/sdk": "1.2.6", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -30,7 +30,8 @@ "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0", - "tauri-plugin-keepawake-api": "^0.1.0" + "tauri-plugin-keepawake-api": "^0.1.0", + "yaml": "^2.4.2" }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7475c486..62caf483 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,8 @@ import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import { Toaster } from "solid-toast" +import useMediaQuery from "@suid/material/useMediaQuery" +import { Minimize2 } from "lucide-solid" import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" import { showConfirmDialog } from "./stores/alerts" @@ -16,6 +18,8 @@ import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" 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 { runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" @@ -70,18 +74,53 @@ const App: Component = () => { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - interface LaunchErrorState { - message: string - binaryPath: string - missingBinary: boolean - } - const [launchError, setLaunchError] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + const phoneQuery = useMediaQuery("(max-width: 767px)") + const isPhoneLayout = createMemo(() => phoneQuery()) + + // In-memory only: hides chrome on phone; may also request browser fullscreen. + const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false) + const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false) + + const fullscreenSupported = () => { + if (typeof document === "undefined") return false + const el = document.documentElement as any + return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function" + } + + const syncBrowserFullscreenState = () => { + if (typeof document === "undefined") return + setBrowserFullscreenActive(Boolean(document.fullscreenElement)) + } + + const enterMobileFullscreen = async () => { + if (!isPhoneLayout()) return + setMobileFullscreenMode(true) + if (!fullscreenSupported()) return + try { + await document.documentElement.requestFullscreen() + } catch { + // Ignore: immersive mode still works without browser fullscreen. + } + } + + const exitMobileFullscreen = async () => { + if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") { + try { + await document.exitFullscreen() + } catch { + // Ignore + } + } + setMobileFullscreenMode(false) + } + createEffect(() => { if (typeof document === "undefined") return const shouldShow = @@ -95,6 +134,56 @@ const App: Component = () => { setInstanceTabBarHeight(element?.offsetHeight ?? 0) } + onMount(() => { + if (typeof document === "undefined") return + syncBrowserFullscreenState() + document.addEventListener("fullscreenchange", syncBrowserFullscreenState) + onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState)) + }) + + onMount(() => { + if (typeof window === "undefined") return + const vv = window.visualViewport + if (!vv) return + + const updateKeyboardOffset = () => { + // visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset. + const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop) + document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`) + } + + const schedule = () => requestAnimationFrame(updateKeyboardOffset) + schedule() + vv.addEventListener("resize", schedule) + vv.addEventListener("scroll", schedule) + window.addEventListener("orientationchange", schedule) + + onCleanup(() => { + vv.removeEventListener("resize", schedule) + vv.removeEventListener("scroll", schedule) + window.removeEventListener("orientationchange", schedule) + document.documentElement.style.removeProperty("--keyboard-offset") + }) + }) + + // If the user exits browser fullscreen via browser UI, restore chrome. + let lastBrowserFullscreen = false + createEffect(() => { + const active = browserFullscreenActive() + const mode = mobileFullscreenMode() + if (mode && lastBrowserFullscreen && !active) { + setMobileFullscreenMode(false) + } + lastBrowserFullscreen = active + }) + + // If we leave phone layout (rotation / resize), restore chrome. + createEffect(() => { + if (!isPhoneLayout() && mobileFullscreenMode()) { + void exitMobileFullscreen() + } + }) + createEffect(() => { void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) }) @@ -152,35 +241,6 @@ const App: Component = () => { 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) { if (!folderPath) { return @@ -199,13 +259,9 @@ const App: Component = () => { port: instances().get(instanceId)?.port, }) } catch (error) { - const message = formatLaunchErrorMessage(error) + const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) const missingBinary = isMissingBinaryMessage(message) - setLaunchError({ - message, - binaryPath: selectedBinary, - missingBinary, - }) + showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) log.error("Failed to create instance", error) } finally { setIsSelectingFolder(false) @@ -310,6 +366,7 @@ const App: Component = () => { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, handleNewInstanceRequest, handleCloseInstance, handleNewSession, @@ -405,19 +462,34 @@ const App: Component = () => { -
+
+ +
+ +
+
- setRemoteAccessOpen(true)} - /> + + setRemoteAccessOpen(true)} + /> + {(instance) => { @@ -435,7 +507,10 @@ const App: Component = () => { handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} onExecuteCommand={executeCommand} - tabBarOffset={instanceTabBarHeight()} + tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} + mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} + onEnterMobileFullscreen={() => void enterMobileFullscreen()} + onExitMobileFullscreen={() => void exitMobileFullscreen()} /> diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index a06568d9..b5c6d5da 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) { const availableAgents = createMemo(() => { const allAgents = instanceAgents() if (isChildSession()) { - return allAgents + return allAgents.filter((agent) => !agent.hidden) } - const filtered = allAgents.filter((agent) => agent.mode !== "subagent") + const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent") const currentAgent = allAgents.find((a) => a.name === props.currentAgent) if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { @@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) { >
> - {(state) => ( + {() => (
- {t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })} + {t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
)} diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 8c01082c..7ef34570 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -115,28 +115,28 @@ const AlertDialog: Component = () => { > -
- -
-
- {accent.symbol} -
-
- {title} - - {payload.message} - {payload.detail &&

{payload.detail}

} -
-
-
+
+ +
+
+ {accent.symbol} +
+
+ {title} + + {payload.message} + {payload.detail &&

{payload.detail}

} +
+
+
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => { {confirmLabel}
-
-
- - - ) - }} - - ) -} +
+
+
+ + ) + }} + + ) + } export default AlertDialog diff --git a/packages/ui/src/components/context-meter.tsx b/packages/ui/src/components/context-meter.tsx new file mode 100644 index 00000000..cd375269 --- /dev/null +++ b/packages/ui/src/components/context-meter.tsx @@ -0,0 +1,123 @@ +import type { Component } from "solid-js" + +interface ContextMeterProps { + usedTokens: number + availableTokens: number | null + formatTokens: (value: number) => string + usedLabel: string + availableLabel: string + class?: string +} + +const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted" + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function resolveFillColor(percent: number): string { + if (percent >= 0.8) return "var(--status-error)" + if (percent >= 0.6) return "var(--status-warning)" + return "var(--status-success)" +} + +export const ContextMeter: Component = (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 ( + + ) + } + + const tooltipText = () => `Context Used: ${percentLabel()}` + + return ( +
+ {circle()} +
+ {props.usedLabel} + {props.formatTokens(used())} + / + {props.availableLabel} + + {available() !== null ? props.formatTokens(available() as number) : "--"} + +
+
+ ) +} + +export default ContextMeter diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6c6b71de..c6694f80 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -12,6 +12,7 @@ interface MonacoDiffViewerProps { after: string viewMode?: "split" | "unified" contextMode?: "expanded" | "collapsed" + wordWrap?: "on" | "off" } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { @@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { scrollBeyondLastLine: false, renderWhitespace: "selection", fontSize: 13, - wordWrap: "off", + wordWrap: props.wordWrap === "on" ? "on" : "off", glyphMargin: false, folding: false, // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. @@ -81,6 +82,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { if (!ready() || !monaco || !diffEditor) return const viewMode = props.viewMode === "unified" ? "unified" : "split" const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded" + const wordWrap = props.wordWrap === "on" ? "on" : "off" diffEditor.updateOptions({ renderSideBySide: viewMode === "split", @@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { contextMode === "collapsed" ? { enabled: true } : { enabled: false }, + wordWrap, }) + + try { + diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap }) + } catch { + // ignore + } + + try { + diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap }) + } catch { + // ignore + } }) createEffect(() => { diff --git a/packages/ui/src/components/info-view.tsx b/packages/ui/src/components/info-view.tsx index 4229b0ec..b2387f5b 100644 --- a/packages/ui/src/components/info-view.tsx +++ b/packages/ui/src/components/info-view.tsx @@ -1,5 +1,5 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" -import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" +import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { ChevronDown } from "lucide-solid" import InstanceInfo from "./instance-info" import { useI18n } from "../lib/i18n" @@ -86,8 +86,8 @@ const InfoView: Component = (props) => { return (
-
- {(inst) => } +
+ {(inst) => }
diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 9231547a..da54521b 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -1,14 +1,21 @@ -import { Component, For, Show, createMemo } from "solid-js" +import { Component, For, Show, createMemo, createSignal } from "solid-js" import type { Instance } from "../types/instance" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import InstanceServiceStatus from "./instance-service-status" import { useI18n } from "../lib/i18n" +import { showConfirmDialog } from "../stores/alerts" +import { disposeInstance } from "../stores/instances" +import { showToastNotification } from "../lib/notifications" +import { getLogger } from "../lib/logger" interface InstanceInfoProps { instance: Instance compact?: boolean + showDisposeButton?: boolean } +const log = getLogger("actions") + const InstanceInfo: Component = (props) => { const { t } = useI18n() const metadataContext = useOptionalInstanceMetadataContext() @@ -16,6 +23,8 @@ const InstanceInfo: Component = (props) => { const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) + const [isDisposing, setIsDisposing] = createSignal(false) + const currentInstance = () => instanceAccessor() const metadata = () => metadataAccessor() const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version @@ -25,6 +34,46 @@ const InstanceInfo: Component = (props) => { return env ? Object.entries(env) : [] }) + const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing()) + + const handleDisposeInstance = async () => { + if (!disposeEnabled()) return + + const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), { + title: t("infoView.dispose.confirm.title"), + variant: "warning", + confirmLabel: t("infoView.dispose.confirm.confirmLabel"), + cancelLabel: t("infoView.dispose.confirm.cancelLabel"), + }) + + if (!confirmed) return + + setIsDisposing(true) + try { + const ok = await disposeInstance(currentInstance().id) + if (ok) { + showToastNotification({ + message: t("infoView.dispose.toast.success"), + variant: "success", + duration: 8000, + }) + } else { + showToastNotification({ + message: t("infoView.dispose.toast.error"), + variant: "error", + }) + } + } catch (error) { + log.error("Failed to dispose instance", error) + showToastNotification({ + message: t("infoView.dispose.toast.error"), + variant: "error", + }) + } finally { + setIsDisposing(false) + } + } + return (
@@ -156,6 +205,19 @@ const InstanceInfo: Component = (props) => {
+ + +
+ +
+
) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ff5d0408..43998a17 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" +import ContextMeter from "../context-meter" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" @@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getSessionStatus } from "../../stores/session-status" -import { ShieldAlert } from "lucide-solid" +import { Maximize2, ShieldAlert } from "lucide-solid" import type { LayoutMode } from "./shell/types" import { @@ -69,6 +70,11 @@ interface InstanceShellProps { handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise onExecuteCommand: (command: Command) => void tabBarOffset: number + + // In-memory only: mobile immersive/fullscreen mode. + mobileFullscreenMode: boolean + onEnterMobileFullscreen: () => void + onExitMobileFullscreen: () => void } const InstanceShell2: Component = (props) => { @@ -117,6 +123,8 @@ const InstanceShell2: Component = (props) => { }) const isPhoneLayout = createMemo(() => layoutMode() === "phone") + const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) + const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") @@ -349,16 +357,6 @@ const InstanceShell2: Component = (props) => { measureDrawerHost, }) - const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) - - - const formattedAvailableTokens = () => { - const avail = tokenStats().avail - if (typeof avail === "number") { - return formatTokenTotal(avail) - } - return "--" - } const renderLeftPanel = () => { if (leftPinned()) { @@ -594,13 +592,14 @@ const InstanceShell2: Component = (props) => { {renderLeftPanel()} - - - -
+ + + + +
= (props) => {
+ + + + + = (props) => {
-
-
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} +
+
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
-
} > @@ -693,18 +699,13 @@ const InstanceShell2: Component = (props) => {
-
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} -
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
+
@@ -720,7 +721,7 @@ const InstanceShell2: Component = (props) => {
-
- - + + + + = (props) => { instanceId={props.instance.id} instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} + isPhoneLayout={isPhoneLayout()} + compactPromptLayout={compactPromptLayout()} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 5e240088..fc0e58aa 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance" import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" import type { DrawerViewState } from "../types" -import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import ChangesTab from "./tabs/ChangesTab" import FilesTab from "./tabs/FilesTab" @@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, @@ -102,6 +103,9 @@ const RightPanel: Component = (props) => { const [diffContextMode, setDiffContextMode] = createSignal( readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", ) + const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", + ) const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) @@ -195,6 +199,11 @@ const RightPanel: Component = (props) => { window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) }) + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode()) + }) + const clampSplitWidth = (value: number) => { const min = 200 const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) @@ -738,8 +747,10 @@ const RightPanel: Component = (props) => { onSelectFile={handleSelectChangesFile} diffViewMode={diffViewMode} diffContextMode={diffContextMode} + diffWordWrapMode={diffWordWrapMode} onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} + onWordWrapModeChange={setDiffWordWrapMode} listOpen={changesListOpen} onToggleList={toggleChangesList} splitWidth={changesSplitWidth} @@ -765,8 +776,10 @@ const RightPanel: Component = (props) => { scopeKey={gitScopeKey} diffViewMode={diffViewMode} diffContextMode={diffContextMode} + diffWordWrapMode={diffWordWrapMode} onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} + onWordWrapModeChange={setDiffWordWrapMode} onOpenFile={(path) => void openGitFile(path)} onRefresh={() => void refreshGitStatus()} listOpen={gitChangesListOpen} diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx index beb249ee..2e9e980b 100644 --- a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -1,50 +1,61 @@ import type { Component } from "solid-js" -import type { DiffContextMode, DiffViewMode } from "../types" +import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid" + +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface DiffToolbarProps { viewMode: DiffViewMode contextMode: DiffContextMode + wordWrapMode: DiffWordWrapMode onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void } const DiffToolbar: Component = (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 (
+ -
) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index 9821d3c5..a4fcd46a 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface ChangesTabProps { t: (key: string, vars?: Record) => string @@ -18,8 +18,10 @@ interface ChangesTabProps { diffViewMode: Accessor diffContextMode: Accessor + diffWordWrapMode: Accessor onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void listOpen: Accessor onToggleList: () => void @@ -77,14 +79,6 @@ const ChangesTab: Component = (props) => { const renderViewer = () => (
-
- -
0 ? selectedFileData : null} @@ -102,6 +96,7 @@ const ChangesTab: Component = (props) => { after={String((file() as any).after || "")} viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} + wordWrap={props.diffWordWrapMode()} /> )} @@ -182,6 +177,17 @@ const ChangesTab: Component = (props) => { -{totals.deletions}
+ +
+ +
} list={{ panel: renderListPanel, overlay: renderListOverlay }} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index bd77d7e5..4575f1e5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface GitChangesTabProps { t: (key: string, vars?: Record) => string @@ -29,8 +29,10 @@ interface GitChangesTabProps { diffViewMode: Accessor diffContextMode: Accessor + diffWordWrapMode: Accessor onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void onOpenFile: (path: string) => void onRefresh: () => void @@ -80,14 +82,6 @@ const GitChangesTab: Component = (props) => { const renderViewer = () => (
-
- -
= (props) => { after={String((file() as any).after || "")} viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} + wordWrap={props.diffWordWrapMode()} /> )} @@ -237,6 +232,15 @@ const GitChangesTab: Component = (props) => { > + + } list={{ panel: renderListPanel, overlay: renderListOverlay }} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 6786b455..e6839af6 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,8 +1,9 @@ import { For, Show, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import { Accordion } from "@kobalte/core" +import { Tooltip } from "@kobalte/core/tooltip" -import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { BackgroundProcess } from "../../../../../../../server/src/api-types" @@ -206,21 +207,25 @@ const StatusTab: Component = (props) => { { id: "session-changes", labelKey: "instanceShell.rightPanel.sections.sessionChanges", + tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip", render: renderStatusSessionChanges, }, { id: "plan", labelKey: "instanceShell.rightPanel.sections.plan", + tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", render: renderPlanSectionContent, }, { id: "background-processes", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", + tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", render: renderBackgroundProcesses, }, { id: "mcp", labelKey: "instanceShell.rightPanel.sections.mcp", + tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", render: () => ( = (props) => { { id: "lsp", labelKey: "instanceShell.rightPanel.sections.lsp", + tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", render: () => ( = (props) => { { id: "plugins", labelKey: "instanceShell.rightPanel.sections.plugins", + tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", render: () => ( = (props) => { - {props.t(section.labelKey)} + + + + + + + + + diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts index e651e528..3273e5ec 100644 --- a/packages/ui/src/components/instance/shell/right-panel/types.ts +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status" export type DiffViewMode = "split" | "unified" export type DiffContextMode = "expanded" | "collapsed" + +export type DiffWordWrapMode = "on" | "off" diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts index 5f1d7421..b17b6743 100644 --- a/packages/ui/src/components/instance/shell/storage.ts +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session- export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" +export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1" export const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) diff --git a/packages/ui/src/components/message-list-header.tsx b/packages/ui/src/components/message-list-header.tsx index 2a7bc6ad..9921164c 100644 --- a/packages/ui/src/components/message-list-header.tsx +++ b/packages/ui/src/components/message-list-header.tsx @@ -1,10 +1,8 @@ import { Show } from "solid-js" import Kbd from "./kbd" +import ContextMeter from "./context-meter" import { useI18n } from "../lib/i18n" -const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary" -const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted" - interface MessageListHeaderProps { usedTokens: number @@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) { const { t } = useI18n() const hasAvailableTokens = () => typeof props.availableTokens === "number" - const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--") return (
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
-
- {t("messageListHeader.metrics.usedLabel")} - {props.formatTokens(props.usedTokens)} -
-
- {t("messageListHeader.metrics.availableLabel")} - {hasAvailableTokens() ? availableDisplay() : "--"} -
+
@@ -55,7 +51,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
) }} diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 9b9e5c01..5d3af5f5 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,5 +1,6 @@ import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" -import { Copy } from "lucide-solid" +import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" +import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" import { useGlobalCache } from "../lib/hooks/use-global-cache" @@ -27,7 +28,17 @@ import type { ToolRendererContext, ToolScrollHelpers, } from "./tool-call/types" -import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" +import { + ensureMarkdownContent, + getRelativePath, + getToolIcon, + getToolName, + isToolStateCompleted, + isToolStateError, + isToolStateRunning, + getDefaultToolAction, + readToolStatePayload, +} from "./tool-call/utils" import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" @@ -155,12 +166,33 @@ export default function ToolCall(props: ToolCallProps) { const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.tool || "" if (toolName === "read") { + const state = toolState() + if (state?.status === "error") { + return true + } return false } return prefExpanded }) const [userExpanded, setUserExpanded] = createSignal(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(null) + const [outputSectionOverride, setOutputSectionOverride] = createSignal(null) + const inputSectionExpanded = () => { + const override = inputSectionOverride() + if (override !== null) return override + return inputDefaultExpanded() + } + const outputSectionExpanded = () => { + const override = outputSectionOverride() + if (override !== null) return override + return true + } const isPermissionActive = createMemo(() => { const pending = pendingPermission() @@ -183,6 +215,35 @@ export default function ToolCall(props: ToolCallProps) { return defaultExpandedForTool() } + const toolInput = createMemo(() => { + const state = toolState() + return readToolStatePayload(state).input + }) + + const hasToolInput = createMemo(() => { + const input = toolInput() + return input && Object.keys(input).length > 0 + }) + + const toolInputMarkdown = createMemo(() => { + const input = toolInput() + if (!input || Object.keys(input).length === 0) return null + + try { + const yamlText = stringifyYaml(input) + return ensureMarkdownContent(yamlText, "yaml", true) + } catch (error) { + log.error("Failed to convert tool call input to YAML", error) + try { + const jsonText = JSON.stringify(input, null, 2) + return ensureMarkdownContent(jsonText, "json", true) + } catch (nestedError) { + log.error("Failed to stringify tool call input", nestedError) + return null + } + } + }) + const permissionDetails = createMemo(() => pendingPermission()?.permission) const questionDetails = createMemo(() => pendingQuestion()?.request) @@ -515,13 +576,13 @@ export default function ToolCall(props: ToolCallProps) { const status = toolState()?.status || "" switch (status) { case "pending": - return "⏸" + return case "running": - return "⏳" + return case "completed": - return "✓" + return case "error": - return "✗" + return default: return "" } @@ -548,6 +609,25 @@ export default function ToolCall(props: ToolCallProps) { }) } + createEffect(() => { + // When global preference changes, reset per-tool-call overrides so palette changes apply. + toolInputsVisibility() + setToolInputVisibilityOverride(null) + setInputSectionOverride(null) + setOutputSectionOverride(null) + }) + + const handleToggleInputVisibility = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (!expanded()) { + toggle() + } + + const currentlyVisible = isToolInputVisible() + setToolInputVisibilityOverride(currentlyVisible ? "hidden" : "expanded") + } + const renderer = createMemo(() => resolveToolRenderer(toolName())) const { renderAnsiContent } = createAnsiContentRenderer({ @@ -789,6 +869,23 @@ export default function ToolCall(props: ToolCallProps) { + + + + + + +
+ {(() => { + const content = toolInputMarkdown() + if (!content) return null + return renderMarkdownContent({ content, cacheKey: "input" }) + })()} +
+
+
+ +
+ + + +
+ {renderToolBody()} + {renderError()} + + +
+ + {t("toolCall.pending.waitingToRun")} +
+
+
+
+
+ + {renderPermissionBlock()} + {renderQuestionBlock()}
)} diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 0f425596..80659458 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -287,7 +287,9 @@ export const taskRenderer: ToolRenderer = { content: promptContent()!, cacheKey: "task:prompt", disableScrollTracking: true, - disableHighlight: true, + // Always use the normal markdown render path for prompt (even while running) + // so the prompt doesn't visually change between running/completed states. + disableHighlight: false, })}
diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 70a0be97..76d7cb7a 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -287,13 +287,14 @@ const UnifiedPicker: Component = (props) => { if (mode() !== "mention") return const query = props.searchQuery.toLowerCase() + const visibleAgents = props.agents.filter((agent) => !agent.hidden) const filtered = query - ? props.agents.filter( + ? visibleAgents.filter( (agent) => agent.name.toLowerCase().includes(query) || (agent.description && agent.description.toLowerCase().includes(query)), ) - : props.agents + : visibleAgents setFilteredAgents(filtered) }) diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index c89a95d4..4105e274 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -1,6 +1,6 @@ import { createSignal, onMount } from "solid-js" import type { Accessor } from "solid-js" -import type { Preferences, ExpansionPreference } from "../../stores/preferences" +import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences" import { createCommandRegistry, type Command } from "../commands" import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" import type { ClientPart, MessageInfo } from "../../types/message" @@ -38,6 +38,7 @@ export interface UseCommandsOptions { setToolOutputExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void + setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void handleNewInstanceRequest: () => void handleCloseInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise @@ -551,6 +552,29 @@ export function useCommands(options: UseCommandsOptions) { }, }) + commandRegistry.register({ + id: "tool-inputs-visibility", + label: () => { + const mode = options.preferences().toolInputsVisibility || "hidden" + const state = + mode === "expanded" + ? tGlobal("commands.common.expanded") + : mode === "collapsed" + ? tGlobal("commands.common.collapsed") + : tGlobal("commands.common.hidden") + return tGlobal("commands.toolInputsVisibility.label", { state }) + }, + description: () => tGlobal("commands.toolInputsVisibility.description"), + category: "System", + keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"), + action: () => { + const mode = options.preferences().toolInputsVisibility || "hidden" + const next: ToolInputsVisibilityPreference = + mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" + options.setToolInputsVisibility(next) + }, + }) + commandRegistry.register({ id: "token-usage-visibility", label: () => { diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index dd0d12f7..b396200c 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse", + "commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}", + "commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments", + "commands.toolInputsVisibility.keywords": "tool, inputs, arguments, visibility, hide, show, expand, collapse", + "commands.tokenUsageDisplay.label": "Token Usage Display · {state}", "commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index 7a25118a..0fddda91 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -39,6 +39,9 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "Open right drawer", "instanceShell.rightDrawer.toggle.close": "Close right drawer", + "instanceShell.fullscreen.enter": "Full screen", + "instanceShell.fullscreen.exit": "Exit full screen", + "instanceShell.metrics.usedLabel": "Used", "instanceShell.metrics.availableLabel": "Avail", @@ -93,11 +96,17 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.actions.refresh": "Refresh", "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.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.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.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.tooltip": "Language Server Protocol servers providing code intelligence, diagnostics, and language-specific features.", "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.loading": "Fetching session changes...", diff --git a/packages/ui/src/lib/i18n/messages/en/logs.ts b/packages/ui/src/lib/i18n/messages/en/logs.ts index a38f681e..22f74872 100644 --- a/packages/ui/src/lib/i18n/messages/en/logs.ts +++ b/packages/ui/src/lib/i18n/messages/en/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.", "infoView.logs.empty.waiting": "Waiting for server output...", "infoView.logs.scrollToBottom": "Scroll to bottom", + + "infoView.dispose.actions.dispose": "Dispose instance", + "infoView.dispose.actions.disposing": "Disposing...", + "infoView.dispose.confirm.title": "Dispose instance?", + "infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.", + "infoView.dispose.confirm.confirmLabel": "Dispose", + "infoView.dispose.confirm.cancelLabel": "Cancel", + "infoView.dispose.toast.success": "Instance disposed. Reloading...", + "infoView.dispose.toast.error": "Failed to dispose instance.", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 400899fe..7b380522 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Diff view mode", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index c6a75e7e..43e1fbb1 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos", "commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar", + "commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}", + "commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta", + "commands.toolInputsVisibility.keywords": "herramienta, entradas, argumentos, visibilidad, ocultar, mostrar, expandir, colapsar", + "commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}", "commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente", "commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index b88d99bf..ee19126b 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -39,13 +39,16 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "Abrir panel derecho", "instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho", + "instanceShell.fullscreen.enter": "Pantalla completa", + "instanceShell.fullscreen.exit": "Salir de pantalla completa", + "instanceShell.metrics.usedLabel": "Usado", "instanceShell.metrics.availableLabel": "Disp.", "instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos", "instanceShell.commandPalette.button": "Paleta de comandos", - "instanceShell.connection.ariaLabel": "Connection {status}", + "instanceShell.connection.ariaLabel": "Conexión {status}", "instanceShell.connection.connected": "Conectada", "instanceShell.connection.connecting": "Conectando...", "instanceShell.connection.disconnected": "Desconectada", @@ -90,16 +93,22 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", - "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion", + "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", + "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.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.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.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.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.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 sesion para ver los cambios.", - "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...", - "instanceShell.sessionChanges.empty": "Aun no hay cambios.", + "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.", + "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...", + "instanceShell.sessionChanges.empty": "Aún no hay cambios.", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.actions.show": "Mostrar cambios", diff --git a/packages/ui/src/lib/i18n/messages/es/logs.ts b/packages/ui/src/lib/i18n/messages/es/logs.ts index ab1cdc68..b9057cfb 100644 --- a/packages/ui/src/lib/i18n/messages/es/logs.ts +++ b/packages/ui/src/lib/i18n/messages/es/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.", "infoView.logs.empty.waiting": "Esperando la salida del servidor...", "infoView.logs.scrollToBottom": "Desplazarse al final", + + "infoView.dispose.actions.dispose": "Desechar instancia", + "infoView.dispose.actions.disposing": "Desechando...", + "infoView.dispose.confirm.title": "¿Desechar instancia?", + "infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.", + "infoView.dispose.confirm.confirmLabel": "Desechar", + "infoView.dispose.confirm.cancelLabel": "Cancelar", + "infoView.dispose.toast.success": "Instancia desechada. Recargando...", + "infoView.dispose.toast.error": "No se pudo desechar la instancia.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index c5a7c177..f0453187 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 63e7c666..505baa19 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics", "commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire", + "commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}", + "commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil", + "commands.toolInputsVisibility.keywords": "outil, entrées, arguments, visibilité, masquer, afficher, développer, réduire", + "commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}", "commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant", "commands.tokenUsageDisplay.keywords": "token, usage, coût, stats", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 09f2ee28..42c56026 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -39,6 +39,9 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit", "instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit", + "instanceShell.fullscreen.enter": "Plein écran", + "instanceShell.fullscreen.exit": "Quitter le plein écran", + "instanceShell.metrics.usedLabel": "Utilisé", "instanceShell.metrics.availableLabel": "Dispo", @@ -91,11 +94,17 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "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.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.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.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.tooltip": "Serveurs du protocole Language Server Protocol fournissant l'intelligence de code et les diagnostics.", "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.loading": "Récupération des changements...", diff --git a/packages/ui/src/lib/i18n/messages/fr/logs.ts b/packages/ui/src/lib/i18n/messages/fr/logs.ts index eda164fa..689fb58c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/logs.ts +++ b/packages/ui/src/lib/i18n/messages/fr/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "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.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 diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index 1af99486..75685849 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 75c1c5f3..de21f94c 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え", "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.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示", "commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index 04a48938..17ceb639 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -39,6 +39,9 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "右ドロワーを開く", "instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる", + "instanceShell.fullscreen.enter": "全画面", + "instanceShell.fullscreen.exit": "全画面を終了", + "instanceShell.metrics.usedLabel": "使用", "instanceShell.metrics.availableLabel": "残り", @@ -91,11 +94,17 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更", + "instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。", "instanceShell.rightPanel.sections.plan": "計画", + "instanceShell.rightPanel.sections.plan.tooltip": "このセッションにおけるエージェントのロードマップ。タスクやサブタスク、および完了状況を追跡します。", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", + "instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "エージェントが開始した長時間実行プロセス。出力を監視し、停止または終了できます。", "instanceShell.rightPanel.sections.mcp": "MCP サーバー", + "instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol (MCP) サーバー。外部ツールやサービスでエージェントの機能を拡張します。", "instanceShell.rightPanel.sections.lsp": "LSP サーバー", + "instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocolサーバーがコードインテリジェンス、診断、言語固有の機能を提供します。", "instanceShell.rightPanel.sections.plugins": "プラグイン", + "instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。", "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", "instanceShell.sessionChanges.loading": "変更を取得中...", diff --git a/packages/ui/src/lib/i18n/messages/ja/logs.ts b/packages/ui/src/lib/i18n/messages/ja/logs.ts index 4498f06f..ed602609 100644 --- a/packages/ui/src/lib/i18n/messages/ja/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ja/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。", "infoView.logs.empty.waiting": "サーバー出力を待機中...", "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 diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 2e5d036f..9251c719 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "diff 表示モード", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 068f020d..55d2a791 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию", "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.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента", "commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 17d4ec36..9db069e6 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -39,6 +39,9 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "Открыть правую панель", "instanceShell.rightDrawer.toggle.close": "Закрыть правую панель", + "instanceShell.fullscreen.enter": "Полный экран", + "instanceShell.fullscreen.exit": "Выйти из полного экрана", + "instanceShell.metrics.usedLabel": "Использовано", "instanceShell.metrics.availableLabel": "Доступно", @@ -91,11 +94,17 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", + "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.", "instanceShell.rightPanel.sections.plan": "План", - "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell", + "instanceShell.rightPanel.sections.plan.tooltip": "Дорожная карта агента для этой сессии. Отслеживает задачи и их статус выполнения.", + "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые оболочки", + "instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Долгоработающие процессы, запущенные агентом. Вы можете следить за их выводом, останавливать или завершать их.", "instanceShell.rightPanel.sections.mcp": "MCP-серверы", + "instanceShell.rightPanel.sections.mcp.tooltip": "Серверы протокола Model Context Protocol, расширяющие возможности агента внешними инструментами.", "instanceShell.rightPanel.sections.lsp": "LSP-серверы", + "instanceShell.rightPanel.sections.lsp.tooltip": "Серверы протокола Language Server Protocol, обеспечивающие интеллектуальную поддержку кода и диагностику.", "instanceShell.rightPanel.sections.plugins": "Плагины", + "instanceShell.rightPanel.sections.plugins.tooltip": "Плагины, настраивающие поведение интерфейса и сервера, добавляющие функции поверх MCP и LSP.", "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", "instanceShell.sessionChanges.loading": "Загрузка изменений...", @@ -125,7 +134,7 @@ export const instanceMessages = { "versionPill.uiWithVersion": "UI {version}", "versionPill.source": " ({source})", - "opencodeBinarySelector.title": "OpenCode Binary", + "opencodeBinarySelector.title": "Бинарник OpenCode", "opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать", "opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…", "opencodeBinarySelector.actions.add": "Добавить", diff --git a/packages/ui/src/lib/i18n/messages/ru/logs.ts b/packages/ui/src/lib/i18n/messages/ru/logs.ts index e9a364b8..dd5cd39d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ru/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.", "infoView.logs.empty.waiting": "Ожидание вывода сервера…", "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 diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index 8ccc6565..6ca953df 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index 85997488..69eba72f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "切换诊断输出是否默认展开", "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.description": "显示或隐藏助手消息的 token 和费用统计", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index a978e220..2b68f3c1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -39,6 +39,9 @@ export const instanceMessages = { "instanceShell.rightDrawer.toggle.open": "打开右侧抽屉", "instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉", + "instanceShell.fullscreen.enter": "全屏", + "instanceShell.fullscreen.exit": "退出全屏", + "instanceShell.metrics.usedLabel": "已用", "instanceShell.metrics.availableLabel": "可用", @@ -91,11 +94,17 @@ export const instanceMessages = { "instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.sections.sessionChanges": "会话更改", + "instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。", "instanceShell.rightPanel.sections.plan": "计划", + "instanceShell.rightPanel.sections.plan.tooltip": "代理的路线图。跟踪任务、子任务及其完成状态。", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", + "instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "代理启动的后台进程。您可以监控其输出、停止或终止它们。", "instanceShell.rightPanel.sections.mcp": "MCP 服务器", + "instanceShell.rightPanel.sections.mcp.tooltip": "模型上下文协议服务器,使用外部工具和服务扩展代理能力。", "instanceShell.rightPanel.sections.lsp": "LSP 服务器", + "instanceShell.rightPanel.sections.lsp.tooltip": "语言服务器协议服务器,提供代码智能、诊断和语言特定的功能。", "instanceShell.rightPanel.sections.plugins": "插件", + "instanceShell.rightPanel.sections.plugins.tooltip": "自定义 UI 和服务器行为的插件,添加超出 MCP 和 LSP 的功能。", "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", "instanceShell.sessionChanges.loading": "正在获取会话更改...", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts index d0b4e9b8..55a3f8f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。", "infoView.logs.empty.waiting": "正在等待服务器输出...", "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 diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 49a848c9..a0f5d30c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", diff --git a/packages/ui/src/lib/launch-errors.ts b/packages/ui/src/lib/launch-errors.ts new file mode 100644 index 00000000..0d495f97 --- /dev/null +++ b/packages/ui/src/lib/launch-errors.ts @@ -0,0 +1,29 @@ +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") + ) +} diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 7df7659f..2e4ee3c1 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { private clients = new Map() - private key(instanceId: string, worktreeSlug: string): string { - return `${instanceId}:${worktreeSlug || "root"}` + private key(instanceId: string, proxyPath: string): string { + return `${instanceId}:${normalizeProxyPath(proxyPath)}` } - createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient { - const key = this.key(instanceId, worktreeSlug) + createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient { + const key = this.key(instanceId, proxyPath) const existing = this.clients.get(key) if (existing) { return existing @@ -23,12 +23,12 @@ class SDKManager { return client } - getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null { - return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null + getClient(instanceId: string, proxyPath: string): OpencodeClient | null { + return this.clients.get(this.key(instanceId, proxyPath)) ?? null } - destroyClient(instanceId: string, worktreeSlug = "root"): void { - this.clients.delete(this.key(instanceId, worktreeSlug)) + destroyClient(instanceId: string, proxyPath: string): void { + this.clients.delete(this.key(instanceId, proxyPath)) } destroyClientsForInstance(instanceId: string): void { @@ -46,7 +46,7 @@ class SDKManager { export type { OpencodeClient } -function buildInstanceBaseUrl(proxyPath: string): string { +export function buildInstanceBaseUrl(proxyPath: string): string { const normalized = normalizeProxyPath(proxyPath) const base = stripTrailingSlashes(CODENOMAD_API_BASE) return `${base}${normalized}/` diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 77a4fdb4..e6354e2c 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent { } } +interface ServerInstanceDisposedEvent { + type: "server.instance.disposed" + properties: { + directory: string + } +} + type SSEEvent = | MessageUpdateEvent | MessageRemovedEvent @@ -74,6 +81,7 @@ type SSEEvent = | TuiToastEvent | BackgroundProcessUpdatedEvent | BackgroundProcessRemovedEvent + | ServerInstanceDisposedEvent | { type: string; properties?: Record } type ConnectionStatus = InstanceStreamStatus @@ -173,6 +181,9 @@ class SSEManager { case "background.process.removed": this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent) break + case "server.instance.disposed": + this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent) + break default: log.warn("Unknown SSE event type", { type: event.type }) } @@ -205,6 +216,7 @@ class SSEManager { onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void + onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): ConnectionStatus | null { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f2a39bda..2d4bb6ec 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss import type { QuestionRequest } from "@opencode-ai/sdk/v2" import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" -import { sdkManager } from "../lib/sdk-manager" +import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" import { serverEvents } from "../lib/server-events" @@ -18,7 +18,14 @@ import { fetchProviders, clearInstanceDraftPrompts, } from "./sessions" -import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" +import { + ensureWorktreesLoaded, + ensureWorktreeMapLoaded, + getOrCreateWorktreeClient, + getWorktreeSlugForSession, + reloadWorktreeMap, + reloadWorktrees, +} from "./worktrees" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -28,6 +35,7 @@ import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestio import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" +import { showWorkspaceLaunchError } from "./launch-errors" const log = getLogger("api") @@ -45,6 +53,8 @@ const permissionSessionCounts = new Map>() const permissionWorktreeSlugByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) +// Track which worktree a question was enqueued under (by question request id). +const questionWorktreeSlugByInstance = new Map>() const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) const questionSessionCounts = new Map>() const questionEnqueuedAt = new Map() @@ -76,6 +86,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal>() +const pendingRehydrations = new Map>() + function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { @@ -228,10 +241,15 @@ async function syncPendingQuestions(instanceId: string): Promise { } } -async function hydrateInstanceData(instanceId: string) { +async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) { try { - await ensureWorktreesLoaded(instanceId) - await ensureWorktreeMapLoaded(instanceId) + if (options?.force) { + await reloadWorktrees(instanceId) + await reloadWorktreeMap(instanceId) + } else { + await ensureWorktreesLoaded(instanceId) + await ensureWorktreeMapLoaded(instanceId) + } await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) @@ -246,6 +264,91 @@ async function hydrateInstanceData(instanceId: string) { } } +async function postInstanceDispose(instanceId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.proxyPath) { + throw new Error("Instance not ready") + } + + const baseUrl = buildInstanceBaseUrl(instance.proxyPath) + const url = new URL("instance/dispose", baseUrl) + + const response = await fetch(url.toString(), { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Dispose request failed with ${response.status}`) + } + + const contentType = response.headers.get("content-type") ?? "" + if (contentType.includes("application/json")) { + const data = await response.json().catch(() => undefined) + if (typeof data === "boolean") return data + if (data && typeof data === "object" && "data" in (data as any)) { + return Boolean((data as any).data) + } + return Boolean(data) + } + + const text = await response.text().catch(() => "") + if (text.trim() === "true") return true + if (text.trim() === "false") return false + return Boolean(text) +} + +async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise { + if (pendingRehydrations.has(instanceId)) { + return pendingRehydrations.get(instanceId) + } + + const promise = (async () => { + const instance = instances().get(instanceId) + if (!instance?.client) { + return + } + + log.info("Rehydrating instance", { instanceId, reason: options?.reason }) + clearCacheForInstance(instanceId) + clearCommands(instanceId) + clearInstanceMetadata(instanceId) + clearInstanceDraftPrompts(instanceId) + clearPermissionQueue(instanceId) + clearQuestionQueue(instanceId) + + await hydrateInstanceData(instanceId, { force: true }) + })().finally(() => { + pendingRehydrations.delete(instanceId) + }) + + pendingRehydrations.set(instanceId, promise) + return promise +} + +async function disposeInstance(instanceId: string): Promise { + if (pendingDisposeRequests.has(instanceId)) { + return pendingDisposeRequests.get(instanceId)! + } + + const promise = (async () => { + const ok = await postInstanceDispose(instanceId) + if (ok) { + await rehydrateInstance(instanceId, { reason: "disposed" }) + } + return ok + })().finally(() => { + pendingDisposeRequests.delete(instanceId) + }) + + pendingDisposeRequests.set(instanceId, promise) + return promise +} + void (async function initializeWorkspaces() { try { const workspaces = await serverApi.fetchWorkspaces() @@ -270,6 +373,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { break case "workspace.error": upsertWorkspace(event.workspace) + showWorkspaceLaunchError(event.workspace) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) @@ -777,6 +881,16 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void if (sessionId) { incrementQuestionSessionPendingCount(instanceId, sessionId) setSessionPendingQuestion(instanceId, sessionId, true) + + // Record the worktree slug at the time the question is enqueued. + // This is used to respond in the same worktree context even from the global permission center. + const slug = getWorktreeSlugForSession(instanceId, sessionId) + let byQuestionId = questionWorktreeSlugByInstance.get(instanceId) + if (!byQuestionId) { + byQuestionId = new Map() + questionWorktreeSlugByInstance.set(instanceId, byQuestionId) + } + byQuestionId.set(request.id, slug) } } @@ -797,6 +911,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { }) questionEnqueuedAt.delete(requestId) + questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { @@ -809,6 +924,7 @@ function clearQuestionQueue(instanceId: string): void { for (const request of getQuestionQueue(instanceId)) { questionEnqueuedAt.delete(request.id) } + questionWorktreeSlugByInstance.delete(instanceId) setQuestionQueues((prev) => { const next = new Map(prev) @@ -834,7 +950,7 @@ function setActiveQuestionIdForInstance(instanceId: string, requestId: string): async function sendQuestionReply( instanceId: string, - _sessionId: string, + sessionId: string, requestId: string, answers: string[][], ): Promise { @@ -844,8 +960,13 @@ async function sendQuestionReply( } try { + const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) + const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" + const worktreeSlug = stored ?? fallback + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + await requestData( - instance.client.question.reply({ + client.question.reply({ requestID: requestId, answers, }), @@ -859,15 +980,20 @@ async function sendQuestionReply( } } -async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise { +async function sendQuestionReject(instanceId: string, sessionId: string, requestId: string): Promise { const instance = instances().get(instanceId) if (!instance?.client) { throw new Error("Instance not ready") } try { + const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) + const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" + const worktreeSlug = stored ?? fallback + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + await requestData( - instance.client.question.reject({ + client.question.reject({ requestID: requestId, }), "question.reject", @@ -939,6 +1065,30 @@ sseManager.onLspUpdated = async (instanceId) => { } } +sseManager.onInstanceDisposed = (sourceInstanceId, event) => { + const directory = event?.properties?.directory + if (!directory) { + void rehydrateInstance(sourceInstanceId, { reason: "disposed" }) + return + } + + const matchingInstanceIds: string[] = [] + for (const instance of instances().values()) { + if (instance.folder === directory) { + matchingInstanceIds.push(instance.id) + } + } + + if (matchingInstanceIds.length === 0) { + void rehydrateInstance(sourceInstanceId, { reason: "disposed" }) + return + } + + for (const instanceId of matchingInstanceIds) { + void rehydrateInstance(instanceId, { reason: "disposed" }) + } +} + async function acknowledgeDisconnectedInstance(): Promise { const pending = disconnectedInstance() if (!pending) { @@ -995,4 +1145,5 @@ export { disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, + disposeInstance, } diff --git a/packages/ui/src/stores/launch-errors.ts b/packages/ui/src/stores/launch-errors.ts new file mode 100644 index 00000000..814790f8 --- /dev/null +++ b/packages/ui/src/stores/launch-errors.ts @@ -0,0 +1,53 @@ +import { createSignal } from "solid-js" +import type { WorkspaceDescriptor } from "../../../server/src/api-types" +import { tGlobal } from "../lib/i18n" +import { formatLaunchErrorMessage, isMissingBinaryMessage } from "../lib/launch-errors" + +type LaunchErrorSource = "create" | "workspace" + +export interface LaunchErrorState { + source: LaunchErrorSource + message: string + binaryPath: string + missingBinary: boolean + instanceId?: string +} + +const [launchError, setLaunchError] = createSignal(null) + +// Avoid spamming the user with the same modal on repeated events. +const lastWorkspaceErrorByInstanceId = new Map() + +export function showLaunchError(next: LaunchErrorState) { + setLaunchError(next) +} + +export function clearLaunchError() { + setLaunchError(null) +} + +export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) { + const instanceId = workspace.id + const rawMessage = workspace.error + const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage")) + + const previous = lastWorkspaceErrorByInstanceId.get(instanceId) + if (previous && previous === message) { + return + } + + lastWorkspaceErrorByInstanceId.set(instanceId, message) + + const binaryPath = (workspace.binaryLabel || workspace.binaryId || "opencode").trim() || "opencode" + const missingBinary = isMissingBinaryMessage(message) + + showLaunchError({ + source: "workspace", + instanceId, + message, + binaryPath, + missingBinary, + }) +} + +export { launchError } diff --git a/packages/ui/src/stores/message-v2/session-info.ts b/packages/ui/src/stores/message-v2/session-info.ts index dd0fe16f..a0970ebf 100644 --- a/packages/ui/src/stores/message-v2/session-info.ts +++ b/packages/ui/src/stores/message-v2/session-info.ts @@ -63,9 +63,14 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { resolveSelectedModel(instanceProviders, latestProviderId, latestModelId) let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT + let modelInputLimit: number | null = null if (selectedModel) { contextWindow = selectedModel.limit?.context ?? 0 + const inputLimit = selectedModel.limit?.input + if (typeof inputLimit === "number" && inputLimit > 0) { + modelInputLimit = inputLimit + } const outputLimit = selectedModel.limit?.output if (typeof outputLimit === "number" && outputLimit > 0) { modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) @@ -107,7 +112,13 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) - if (!contextAvailableFromPrevious) { + if (modelInputLimit !== null) { + // Prefer explicit input limits when provided by the API. + // This is used by the UI "Avail" chip. + contextAvailableTokens = modelInputLimit + } + + if (!contextAvailableFromPrevious && contextAvailableTokens === null) { if (contextWindow > 0) { if (latestHasContextUsage && actualUsageTokens > 0) { contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0) diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 96ec386a..8ac2ead0 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -25,6 +25,7 @@ export interface ModelPreference { export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" +export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" export type ListeningMode = "local" | "all" export interface UiSettings { @@ -37,6 +38,7 @@ export interface UiSettings { diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference + toolInputsVisibility: ToolInputsVisibilityPreference showUsageMetrics: boolean autoCleanupBlankSessions: boolean @@ -108,6 +110,7 @@ const defaultUiSettings: UiSettings = { diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", + toolInputsVisibility: "collapsed", showUsageMetrics: true, autoCleanupBlankSessions: true, @@ -130,6 +133,10 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + toolInputsVisibility: + sanitized.toolInputsVisibility === "hidden" || sanitized.toolInputsVisibility === "collapsed" || sanitized.toolInputsVisibility === "expanded" + ? sanitized.toolInputsVisibility + : defaultUiSettings.toolInputsVisibility, showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, @@ -304,10 +311,10 @@ function setThemePreference(preference: ThemePreference): void { void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) } -function setListeningMode(mode: ListeningMode): void { - if (serverSettings().listeningMode === mode) return - void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error)) -} + async function setListeningMode(mode: ListeningMode): Promise { + if (serverSettings().listeningMode === mode) return + await patchConfigOwner("server", { listeningMode: mode }) + } function updateEnvironmentVariables(envVars: Record): void { void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => @@ -439,6 +446,11 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void { updateUiSettings({ diagnosticsExpansion: mode }) } +function setToolInputsVisibility(mode: ToolInputsVisibilityPreference): void { + if (preferences().toolInputsVisibility === mode) return + updateUiSettings({ toolInputsVisibility: mode }) +} + function setThinkingBlocksExpansion(mode: ExpansionPreference): void { if (preferences().thinkingBlocksExpansion === mode) return updateUiSettings({ thinkingBlocksExpansion: mode }) @@ -536,6 +548,7 @@ interface ConfigContextValue { setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion + setToolInputsVisibility: typeof setToolInputsVisibility // instance scoped setAgentModelPreference: typeof setAgentModelPreference @@ -579,6 +592,7 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, setAgentModelPreference, getAgentModelPreference, } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index dc8ad63e..6c904fd7 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -291,12 +291,13 @@ async function createSession(instanceId: string, agent?: string): Promise p.id === session.model.providerId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialContextWindow = initialModel?.limit?.context ?? 0 + const initialInputLimit = initialModel?.limit?.input ?? 0 const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 const initialOutputLimit = initialModel?.limit?.output && initialModel.limit.output > 0 ? initialModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT - const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null + const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) @@ -398,10 +399,11 @@ async function forkSession( const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkContextWindow = forkModel?.limit?.context ?? 0 + const forkInputLimit = forkModel?.limit?.input ?? 0 const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 const forkOutputLimit = forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT - const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null + const forkContextAvailable = forkInputLimit > 0 ? forkInputLimit : forkContextWindow > 0 ? forkContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) @@ -524,6 +526,7 @@ async function fetchAgents(instanceId: string): Promise { name: agent.name, description: agent.description || "", mode: agent.mode, + hidden: agent.hidden, model: agent.model?.modelID ? { providerId: agent.model.providerID || "", diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 58d2a64a..0d6ed470 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -40,7 +40,7 @@ import { } from "./instances" import { showAlertDialog } from "./alerts" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" -import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { tGlobal } from "../lib/i18n" @@ -108,6 +108,8 @@ interface TuiToastEvent { const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { + let parentToExpand: string | null = null + withSession(instanceId, sessionId, (session) => { const current = session.status ?? "idle" if (current === status) return false @@ -117,7 +119,17 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi } session.status = status + + // Auto-expand the parent thread when a child session starts working. + // Users can still collapse it; we only expand on the transition. + if (session.parentId && status === "working" && current !== "working") { + parentToExpand = session.parentId + } }) + + if (parentToExpand) { + ensureSessionParentExpanded(instanceId, parentToExpand) + } } async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise { @@ -158,6 +170,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) let updatedInstanceSessions: Map | undefined + let shouldExpandParent: string | null = null setSessions((prev) => { const next = new Map(prev) @@ -174,11 +187,19 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) updatedInstanceSessions = instanceSessions + + if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") { + shouldExpandParent = merged.parentId + } return next }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) + if (shouldExpandParent) { + ensureSessionParentExpanded(instanceId, shouldExpandParent) + } + return fetched } catch (error) { log.error("Failed to fetch session info", error) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 89019ce5..d0e57c37 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -347,10 +347,23 @@ function clearActiveParentSession(instanceId: string): void { } function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void { + let parentToExpand: string | null = null + withSession(instanceId, sessionId, (session) => { if (session.status === status) return false + const previous = session.status session.status = status + + // If a child session starts working, auto-expand its parent thread once. + // Users can still collapse it afterwards; we only expand on the transition. + if (session.parentId && status === "working" && previous !== "working") { + parentToExpand = session.parentId + } }) + + if (parentToExpand) { + ensureSessionParentExpanded(instanceId, parentToExpand) + } } function getActiveParentSession(instanceId: string): Session | null { diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index cc541ddc..4337d32c 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string { return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance` } +function encodeBase64UrlUtf8(input: string): string { + const bytes = new TextEncoder().encode(input) + // Convert bytes -> base64 (btoa expects a binary string) + let binary = "" + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + binary += String.fromCharCode(...chunk) + } + const base64 = btoa(binary) + // base64 -> base64url (strip padding) + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string { + const base = buildWorktreeProxyPath(instanceId, slug) + const encoded = encodeBase64UrlUtf8(directory) + return `${base}/__dir/${encoded}` +} + function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient { const normalized = normalizeWorktreeSlug(instanceId, slug || "root") const proxyPath = buildWorktreeProxyPath(instanceId, normalized) return sdkManager.createClient(instanceId, proxyPath, normalized) } +function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient { + const normalized = normalizeWorktreeSlug(instanceId, slug || "root") + const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory) + return sdkManager.createClient(instanceId, proxyPath, normalized) +} + function getRootClient(instanceId: string): OpencodeClient { return getOrCreateWorktreeClient(instanceId, "root") } @@ -359,7 +385,9 @@ export { removeParentSessionMapping, getWorktreeSlugForDirectory, buildWorktreeProxyPath, + buildWorktreeProxyPathWithDirectoryOverride, getOrCreateWorktreeClient, + getOrCreateWorktreeClientWithDirectoryOverride, getRootClient, createWorktree, deleteWorktree, diff --git a/packages/ui/src/styles/components/buttons.css b/packages/ui/src/styles/components/buttons.css index 71c6c4c5..105d42a4 100644 --- a/packages/ui/src/styles/components/buttons.css +++ b/packages/ui/src/styles/components/buttons.css @@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) { button.button-tertiary:focus-visible { box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); } + +.button-danger, +button.button-danger { + @apply px-6 py-3 text-base rounded-lg; + background-color: var(--button-danger-bg); + color: var(--button-danger-text); + border-color: var(--button-danger-bg); +} + +.button-danger:hover:not(:disabled), +button.button-danger:hover:not(:disabled) { + background-color: var(--button-danger-hover-bg); + border-color: var(--button-danger-hover-bg); +} + +.button-danger:focus-visible, +button.button-danger:focus-visible { + box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); +} + +/* Smaller sizing variant for destructive actions in tight spaces. */ +.button-danger.button-small, +button.button-danger.button-small { + @apply px-4 py-2 text-sm; +} diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 142e8e68..d8b89be7 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -9,6 +9,9 @@ line-height: var(--line-height-normal); font-weight: var(--font-weight-regular); color: var(--text-primary); + /* Message containers may use `whitespace-pre-wrap` for plain text. + Markdown should always match assistant rendering (normal whitespace). */ + white-space: normal; } .markdown-body p, @@ -28,7 +31,7 @@ .markdown-body h5, .markdown-body h6 { font-family: inherit; - color: inherit; + color: var(--markdown-heading-color, inherit); font-weight: var(--font-weight-semibold); line-height: 1.3; margin-top: 0.9em; @@ -71,7 +74,7 @@ .markdown-body strong { font-weight: var(--font-weight-regular); - color: var(--message-assistant-border); + color: var(--markdown-accent, var(--message-assistant-border)); } .markdown-body em { diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 8064ae62..5f45939a 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -1,6 +1,10 @@ /* Message item base styles */ .message-item-base { @apply flex flex-col gap-2 p-3 w-full; + + /* Markdown rendering uses these to theme emphasis + headings per message role. */ + --markdown-accent: var(--message-user-border); + --markdown-heading-color: var(--message-user-border); } .message-item-header { @@ -71,6 +75,9 @@ padding: 0.6rem 0.65rem; margin-top: 0; margin-bottom: 0; + + --markdown-accent: var(--message-assistant-border); + --markdown-heading-color: var(--text-primary); } .message-item-base:not(.assistant-message) { diff --git a/packages/ui/src/styles/messaging/message-section.css b/packages/ui/src/styles/messaging/message-section.css index 1579fc3c..15ded9e5 100644 --- a/packages/ui/src/styles/messaging/message-section.css +++ b/packages/ui/src/styles/messaging/message-section.css @@ -130,6 +130,19 @@ color: var(--text-primary); } +/* Make the command palette trigger stand out in the header. */ +.connection-status-button.command-palette-button { + border-radius: 0; + @apply text-sm px-2 py-1 border border-base transition-colors; + background-color: var(--surface-base); + color: var(--text-primary); +} + +.connection-status-button.command-palette-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + .connection-status-button:hover { background-color: var(--surface-hover); } diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 4797c241..8cbfb796 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -295,7 +295,33 @@ } .prompt-input { - padding-bottom: 1.5rem; + /* Prevent iOS Safari input zoom + keep input compact. */ + font-size: 16px; + padding-bottom: 0.75rem; + } +} + +@media (max-width: 1279px) { + :root { + --prompt-input-compact-height: 104px; + } + + .prompt-input-wrapper { + min-height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container { + min-height: var(--prompt-input-compact-height); + height: var(--prompt-input-compact-height); + } + + .prompt-input-field { + height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container.is-expanded, + .prompt-input-field.is-expanded { + height: auto; } } @@ -307,9 +333,9 @@ @media (max-width: 640px) { .prompt-input { - min-height: 64px; + min-height: 0; padding: 0.5rem 0.75rem; - padding-bottom: 2.25rem; + padding-bottom: 0.75rem; } .prompt-input-wrapper { diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 47b732fb..8997f1e8 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -87,6 +87,7 @@ @apply flex items-stretch w-full; background-color: transparent; color: var(--text-primary); + border-bottom: 1px solid var(--tool-call-border-color); } .tool-call-header:hover { @@ -127,11 +128,30 @@ cursor: pointer; } +.tool-call-header-input { + @apply inline-flex items-center justify-center; + background-color: transparent; + border: none; + color: var(--text-secondary); + padding: 0 0.5rem; + border-radius: 0; + cursor: pointer; +} + .tool-call-header-copy:hover { background-color: transparent; color: var(--text-primary); } +.tool-call-header-input:hover { + background-color: transparent; + color: var(--text-primary); +} + +.tool-call-header-input[aria-pressed="true"] { + color: var(--text-primary); +} + .tool-call-header-status { @apply inline-flex items-center justify-center; font-size: 0.95rem; @@ -213,6 +233,63 @@ font-size: var(--font-size-xs); } + +.tool-call-io-sections { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0; +} + +.tool-call-io-section { + border: 1px solid var(--tool-call-border-color); + overflow: hidden; + background-color: transparent; + border-radius: 0; +} + +.tool-call-io-toggle { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.75rem; + padding: 0.5rem; + background-color: var(--surface-secondary); + border: none; + border-bottom: 1px solid var(--tool-call-border-color); + width: 100%; + text-align: left; + font-size: 0.875rem; + font-weight: normal; + color: var(--text-primary); + cursor: pointer; +} + +.tool-call-io-toggle::before { + content: "▶"; + font-size: 11px; + margin-right: 0.35rem; + color: var(--text-secondary); +} + +.tool-call-io-toggle[aria-expanded="true"]::before { + content: "▼"; +} + +.tool-call-io-title { + font-weight: inherit; + color: inherit; +} + +.tool-call-io-body { + background-color: var(--surface-code); +} + +/* IO sections provide the outer frame; avoid double borders on markdown frames. */ +.tool-call-io-body .tool-call-markdown { + border: none; +} + .tool-call-markdown { background-color: var(--surface-code); /* Keep a visible frame around the scroll viewport (not the content). */ diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index f2bfb016..6b9d2fcd 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -282,7 +282,7 @@ } .file-viewer-toolbar { - @apply ml-auto flex items-center gap-1; + @apply flex items-center gap-1; } .file-viewer-toolbar-button { @@ -291,6 +291,22 @@ color: var(--text-secondary); } +.file-viewer-toolbar-icon-button { + @apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors; + background-color: var(--surface-base); + color: var(--text-secondary); +} + +.file-viewer-toolbar-icon-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.file-viewer-toolbar-icon-button.active { + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--accent-primary); +} + .file-viewer-toolbar-button:hover { background-color: var(--surface-hover); color: var(--text-primary); @@ -396,7 +412,7 @@ } .right-panel-accordion-trigger { - @apply w-full flex items-center justify-between gap-3 px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150; + @apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150; color: var(--text-secondary); background-color: transparent; } @@ -406,6 +422,11 @@ color: var(--text-primary); } +.section-left { + @apply flex items-center; + flex-shrink: 0; +} + .right-panel-accordion-chevron { @apply h-4 w-4 transition-transform duration-200; color: var(--text-muted); @@ -425,6 +446,51 @@ min-height: 0; } +/* Section info tooltip */ +.section-info-trigger { + @apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150; + color: var(--text-muted); + flex-shrink: 0; +} + +.section-info-trigger:hover { + color: var(--text-primary); + background-color: var(--surface-hover); +} + +.section-label { + margin-left: 2px; +} + +.section-info-icon { + @apply w-3.5 h-3.5; +} + +.section-info-tooltip { + @apply max-w-xs px-3 py-2 text-xs rounded-lg border shadow-lg; + background-color: var(--surface-base); + border-color: var(--border-base); + color: var(--text-primary); + animation: tooltipShow 150ms ease-out; + transform-origin: var(--kb-tooltip-content-transform-origin); + z-index: 9999; +} + +.section-info-tooltip[data-expanded] { + animation: tooltipShow 150ms ease-out; +} + +@keyframes tooltipShow { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + /* Background process cards in status panel */ .status-process-card { @apply rounded-lg border flex flex-col gap-2 p-3 transition-all duration-150; diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 845f7c69..f03cf6a7 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -125,6 +125,18 @@ session-sidebar-controls .selector-trigger-primary { width: 100%; } +.mobile-fullscreen-exit-wrapper { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 12px); + right: calc(env(safe-area-inset-right, 0px) + 12px); + z-index: 1250; + pointer-events: none; +} + +.mobile-fullscreen-exit-button { + pointer-events: auto; +} + .session-resize-handle { @apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors; z-index: 10; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 44aaa1fd..1e387127 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -64,6 +64,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary) { @apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md; @@ -74,6 +76,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary):focus-visible { outline: none; @@ -84,6 +88,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary):disabled { @apply cursor-not-allowed opacity-50; diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 1cae7c21..ab7f6c89 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -68,6 +68,7 @@ export interface Agent { name: string description: string mode: string + hidden?: boolean model?: { providerId: string modelId: string @@ -90,6 +91,7 @@ export interface Model { variantKeys?: string[] limit?: { context?: number + input?: number output?: number } cost?: { diff --git a/temp/opencode-ai-sdk-1.2.6.tgz b/temp/opencode-ai-sdk-1.2.6.tgz new file mode 100644 index 00000000..cf504faa Binary files /dev/null and b/temp/opencode-ai-sdk-1.2.6.tgz differ diff --git a/temp/package/package.json b/temp/package/package.json new file mode 100644 index 00000000..efd53ec2 --- /dev/null +++ b/temp/package/package.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk", + "version": "1.2.6", + "type": "module", + "license": "MIT", + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "./script/build.ts" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./client": { + "import": "./dist/client.js", + "types": "./dist/client.d.ts" + }, + "./server": { + "import": "./dist/server.js", + "types": "./dist/server.d.ts" + }, + "./v2": { + "import": "./dist/v2/index.js", + "types": "./dist/v2/index.d.ts" + }, + "./v2/client": { + "import": "./dist/v2/client.js", + "types": "./dist/v2/client.d.ts" + }, + "./v2/server": { + "import": "./dist/v2/server.js", + "types": "./dist/v2/server.d.ts" + } + }, + "files": [ + "dist" + ], + "devDependencies": { + "@hey-api/openapi-ts": "0.90.10", + "@tsconfig/node22": "22.0.2", + "@types/node": "22.13.9", + "typescript": "5.8.2", + "@typescript/native-preview": "7.0.0-dev.20251207.1" + }, + "dependencies": {}, + "publishConfig": { + "directory": "dist" + } +} \ No newline at end of file