Compare commits

...

54 Commits

Author SHA1 Message Date
Shantur Rathore
c825ff066e bump version to 0.5.1 2026-01-07 08:56:19 +00:00
Shantur Rathore
f7ded37ea3 Fix macOS tauri cli package name 2026-01-07 06:35:59 +00:00
Shantur Rathore
847faf1214 Fix Tauri builds and Windows opencode-config loop 2026-01-07 06:25:19 +00:00
Shantur Rathore
b1691add1c Stabilize Tauri CLI install in CI 2026-01-07 06:10:30 +00:00
Shantur Rathore
3b9a44779a Exclude opencode-config from npm workspaces 2026-01-06 23:20:45 +00:00
Shantur Rathore
62fd88cd3f Install @tauri-apps/cli alongside platform bindings 2026-01-06 23:10:45 +00:00
Shantur Rathore
ce2273fe57 Sync package-lock for opencode-config workspace 2026-01-06 23:03:54 +00:00
Shantur Rathore
0eee325777 Stabilize Windows opencode-config install and pin tauri bindings 2026-01-06 22:57:38 +00:00
Shantur Rathore
f7c9db44ad Fix Windows builds by tracking opencode-config package.json 2026-01-06 22:43:46 +00:00
Shantur Rathore
1fcf89b945 Isolate opencode-config install from workspace 2026-01-06 20:58:01 +00:00
Shantur Rathore
f5682ea246 Fix dev CI build tool resolution and Windows npm spawning 2026-01-06 20:45:40 +00:00
Shantur Rathore
fa308696b4 Allow callers to control workflow permissions 2026-01-06 20:32:29 +00:00
Shantur Rathore
ac8dfcc607 Improve opencode-config install on Windows 2026-01-06 20:30:40 +00:00
Shantur Rathore
ac04d5daf7 Run build-only CI on dev pushes 2026-01-06 20:30:37 +00:00
Shantur Rathore
7fe8fee295 Fix Tauri CLI native dependency installs 2026-01-06 20:30:33 +00:00
Shantur Rathore
5954b332d5 Update dependencies 2026-01-06 19:38:08 +00:00
Shantur Rathore
eb89dfaf89 Fix iOS input auto-zoom (fixes #49, thanks @xpcmdshell) 2026-01-06 18:51:39 +00:00
Shantur Rathore
25bf313338 Show compaction indicator in message stream and timeline 2026-01-06 18:48:00 +00:00
Shantur Rathore
315abf21e6 Fix session status hydration and compaction transitions 2026-01-06 18:03:42 +00:00
Shantur Rathore
f24e360d78 Optimize session status updates
Reduce per-token store churn by updating status on transitions, caching instance-level indicators, and avoiding O(n) session-map cloning.
2026-01-06 09:58:55 +00:00
Shantur Rathore
1a6f1fdbae Bump to v0.5.0 2026-01-05 22:39:02 +00:00
Shantur Rathore
e09ce0780e Reconcile permissions after message hydration
After loadMessages hydrates tool parts, reattach pending permissions to the correct tool-call part ids so ToolCall permission UI renders reliably.
2026-01-05 20:39:51 +00:00
Shantur Rathore
95fdad7523 Use shield icon for permission status
Replace permission dots with a shield indicator and adjust permission colors to stand out from working/compacting.
2026-01-05 20:18:07 +00:00
Shantur Rathore
06416a9eb3 Add instance tab session status indicator
Aggregate session states per instance so tabs reflect permission, compaction, and working activity.
2026-01-05 20:09:13 +00:00
Shantur Rathore
2db62b1d17 Make UI global cache version-aware
Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys.
2026-01-05 19:45:33 +00:00
Shantur Rathore
1377bc6b91 Migrate UI to v2 SDK client
Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
2026-01-04 22:02:30 +00:00
Shantur Rathore
fcb5998474 Update UI permissions for SDK 1.0.166
Handle permission.asked events and requestID replies while keeping legacy compatibility.
2026-01-04 22:02:30 +00:00
Shantur Rathore
c2df32ec8b Stream ANSI tool output rendering 2026-01-04 22:02:30 +00:00
Shantur Rathore
f01149ee9e Stream ANSI tool output rendering 2026-01-04 22:02:29 +00:00
Shantur Rathore
eebfcb5628 Unify ANSI rendering with sequence parser 2026-01-04 22:02:29 +00:00
Shantur Rathore
4571a1dcf9 Render ANSI background output 2026-01-04 22:02:29 +00:00
Shantur Rathore
a041e1c6c3 Track session status via SSE updates 2026-01-04 22:02:29 +00:00
Shantur Rathore
abb8a9df19 Merge pull request #51 from bizzkoot/fix/copy-button-web
fix: copy button functionality in web browsers
2026-01-04 13:52:41 +00:00
bizzkoot
3c450c076a fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts
- Implement modern Clipboard API with document.execCommand fallback
- Update copy buttons in code blocks, markdown, messages, and session list
- Add proper error handling and user feedback for copy operations

Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
2026-01-04 20:00:22 +08:00
Shantur Rathore
4b05e698f8 Require tool part ids for tool-call rendering and caching
Rebind permissions from callID to part id when parts arrive.
2026-01-02 16:21:24 +00:00
Shantur Rathore
a9524b3e30 Load complete background process output and fix dialog layout 2025-12-30 22:03:04 +00:00
Shantur Rathore
154c5208b4 Show timeline icons at all widths 2025-12-29 16:19:11 +00:00
Shantur Rathore
71479a59a7 Add ANSI rendering for bash tool output 2025-12-26 10:47:53 +00:00
Shantur Rathore
3606d9aa50 Enforce workspace-only paths for background processes 2025-12-25 23:15:43 +00:00
Shantur Rathore
3e4d51c9f2 Surface runtime output in launch errors 2025-12-25 20:44:21 +00:00
Shantur Rathore
2603b1d260 Handle revert removals locally and retarget prompt input 2025-12-25 15:12:44 +00:00
Shantur Rathore
94aa469e90 Stop workspace port warning timer after allocation 2025-12-24 20:29:11 +00:00
Shantur Rathore
dab1e0fa7a Bundle opencode-config dependencies 2025-12-24 20:25:19 +00:00
Shantur Rathore
a14247f049 Sync package-lock 2025-12-24 19:10:32 +00:00
Shantur Rathore
695a890e0a Normalize plugin file URLs 2025-12-24 13:37:39 +00:00
Shantur Rathore
402d72d038 Remove session idle plugin wiring 2025-12-24 13:34:46 +00:00
Shantur Rathore
d32ec73c63 Resolve bundled opencode config from resources 2025-12-24 13:30:00 +00:00
Shantur Rathore
d0eac1e610 Use bundled opencode config at runtime 2025-12-24 12:01:03 +00:00
Shantur Rathore
e947691aae Consolidate plugins under CodeNomad entry 2025-12-24 01:07:56 +00:00
Shantur Rathore
575f987b8f Add background process manager and UI panel 2025-12-24 00:59:41 +00:00
Shantur Rathore
28b66ed0af Add CodeNomad plugin bridge for opencode 2025-12-23 23:06:33 +00:00
Shantur Rathore
4060c4f60b Show configured plugins in status panels 2025-12-23 18:24:09 +00:00
Shantur Rathore
8334e27294 Show error if opencode fails to launch 2025-12-17 22:59:05 +00:00
Shantur Rathore
722b523f92 Add packages/opencode-config and use it 2025-12-17 22:58:41 +00:00
81 changed files with 4120 additions and 2262 deletions

View File

@@ -4,21 +4,33 @@ on:
workflow_call: workflow_call:
inputs: inputs:
version: version:
description: "Version to apply to workspace packages" description: "Version to apply to workspace packages (release builds)"
required: true required: false
default: ""
type: string type: string
tag: tag:
description: "Git tag to upload assets to" description: "Git tag to upload assets to (release builds)"
required: true required: false
default: ""
type: string type: string
release_name: release_name:
description: "Release name (unused here, for context)" description: "Release name (unused here, for context)"
required: true required: false
default: ""
type: string type: string
upload:
description: "Upload built artifacts to the GitHub release"
required: false
default: true
type: boolean
set_versions:
description: "Run npm version to set workspace versions"
required: false
default: true
type: boolean
permissions: # Permissions are intentionally omitted here so callers can choose
id-token: write # least-privilege (e.g. dev CI uses read-only; releases grant write).
contents: write
env: env:
NODE_VERSION: 20 NODE_VERSION: 20
@@ -41,10 +53,11 @@ jobs:
cache: npm cache: npm
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save run: npm install @rollup/rollup-darwin-x64 --no-save
@@ -53,6 +66,7 @@ jobs:
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -79,11 +93,12 @@ jobs:
cache: npm cache: npm
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash shell: bash
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save run: npm install @rollup/rollup-win32-x64-msvc --no-save
@@ -92,6 +107,7 @@ jobs:
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh shell: pwsh
run: | run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object { Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ jobs:
cache: npm cache: npm
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -128,6 +145,7 @@ jobs:
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -157,18 +175,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri) - name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS) - name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -180,6 +218,7 @@ jobs:
fi fi
- name: Upload Tauri release assets (macOS) - name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri, arm64) - name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64) - name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -232,6 +291,7 @@ jobs:
fi fi
- name: Upload Tauri release assets (macOS arm64) - name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash shell: bash
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
shell: bash
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Windows bundle (Tauri) - name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app shell: bash
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows) - name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
shell: pwsh shell: pwsh
run: | run: |
$bundleRoot = "packages/tauri-app/target/release/bundle" $bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -287,6 +369,7 @@ jobs:
} }
- name: Upload Tauri release assets (Windows) - name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh shell: pwsh
run: | run: |
if (Test-Path "packages/tauri-app/release-tauri") { if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,18 +412,38 @@ jobs:
librsvg2-dev librsvg2-dev
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Linux bundle (Tauri) - name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux) - name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target" SEARCH_ROOT="packages/tauri-app/target"
@@ -367,6 +470,7 @@ jobs:
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm" cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux) - name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -429,7 +533,7 @@ jobs:
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save run: npm install @rollup/rollup-linux-arm64-gnu --no-save
@@ -497,10 +601,11 @@ jobs:
sudo gem install --no-document fpm sudo gem install --no-document fpm
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies - name: Install project dependencies
run: npm ci --workspaces run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary - name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -509,6 +614,7 @@ jobs:
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets - name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob

View File

@@ -1,16 +1,18 @@
name: Dev Release name: Dev CI
on: on:
push:
branches:
- dev
workflow_dispatch: workflow_dispatch:
permissions: permissions:
id-token: write contents: read
contents: write
jobs: jobs:
dev-release: dev-ci:
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
version_suffix: -dev upload: false
dist_tag: dev set_versions: false
secrets: inherit secrets: inherit

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ release/
.electron-vite/ .electron-vite/
out/ out/
.dir-locals.el .dir-locals.el
.opencode/bashOutputs/

1617
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.4.0", "version": "0.5.1",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/*" "packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
] ]
}, },
"scripts": { "scripts": {
@@ -23,5 +26,8 @@
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.4.0", "version": "0.5.1",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",
@@ -69,6 +69,10 @@
"!icon.icns", "!icon.icns",
"!icon.ico" "!icon.ico"
] ]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
} }
], ],
"mac": { "mac": {

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process" import { spawn } from "child_process"
import { existsSync } from "fs" import { existsSync } from "fs"
import { join } from "path" import path, { join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url)) const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) { function run(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
const binPaths = [
join(nodeModulesPath, ".bin"),
join(workspaceNodeModulesPath, ".bin"),
]
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
const spawnOptions = { const spawnOptions = {
cwd: appDir, cwd: appDir,
stdio: "inherit", stdio: "inherit",
shell: process.platform === "win32", shell: process.platform === "win32",
...options, ...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }, env,
} }
const child = spawn(command, args, spawnOptions) const child = spawn(command, args, spawnOptions)

View File

@@ -0,0 +1,32 @@
# opencode-config
## TLDR
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
## What it is
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
## How it works
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
## Expectations
- Local-only bridge (no auth/token yet).
- Plugin must fail startup if it cannot connect after 3 retries.
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
- Keep event shapes small and explicit; use `type` + `properties` only.
## Ideas
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
- Promote stable event shapes and version tags once the protocol settles.
## Pointers
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
- Workspace env injection: `packages/server/src/workspaces/manager.ts`

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -0,0 +1,8 @@
{
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.1"
}
}

View File

@@ -0,0 +1,32 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
void client.postEvent({
type: "codenomad.pong",
properties: {
ts: Date.now(),
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
}
})
return {
tool: {
...backgroundProcessTools,
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
},
}
}

View File

@@ -0,0 +1,309 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
type BackgroundProcess = {
id: string
title: string
command: string
status: "running" | "stopped" | "error"
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
type BackgroundProcessOptions = {
baseDir: string
}
type ParsedCommand = {
head: string
args: string[]
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const base = config.baseUrl.replace(/\/+$/, "")
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined) {
headers["Content-Type"] = "application/json"
}
const response = await fetch(url, {
...init,
headers,
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
return {
run_background_process: tool({
description:
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"),
},
async execute(args) {
assertCommandWithinBase(args.command, options.baseDir)
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
},
}),
list_background_processes: tool({
description: "List background processes running for this workspace.",
args: {},
async execute() {
const response = await request<{ processes: BackgroundProcess[] }>("")
if (response.processes.length === 0) {
return "No background processes running."
}
return response.processes
.map((process) => {
const status = process.status === "running" ? "running" : process.status
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
const size =
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
})
.join("\n")
},
}),
read_background_process_output: tool({
description: "Read output from a background process. Use full, grep, head, or tail.",
args: {
id: tool.schema.string().describe("Background process ID"),
method: tool.schema
.enum(["full", "grep", "head", "tail"])
.default("full")
.describe("Method to read output"),
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
},
async execute(args) {
if (args.method === "grep" && !args.pattern) {
return "Pattern is required for grep method."
}
const params = new URLSearchParams({ method: args.method })
if (args.pattern) {
params.set("pattern", args.pattern)
}
if (args.lines) {
params.set("lines", String(args.lines))
}
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
`/${args.id}/output?${params.toString()}`,
)
const header = response.truncated
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
return `${header}\n\n${response.content}`
},
}),
stop_background_process: tool({
description: "Stop a background process (SIGTERM) but keep its output and entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
},
}),
terminate_background_process: tool({
description: "Terminate a background process and delete its output + entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
await request<void>(`/${args.id}/terminate`, { method: "POST" })
return `Terminated background process ${args.id} and removed its output.`
},
}),
}
}
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
const EXPANSION_CHARS = /[~*$?\[\]`$]/
function assertCommandWithinBase(command: string, baseDir: string) {
const normalizedBase = path.resolve(baseDir)
const commands = splitCommands(command)
for (const item of commands) {
if (!FILE_COMMANDS.has(item.head)) {
continue
}
for (const arg of item.args) {
if (!arg) continue
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
const literalArg = unquote(arg)
if (EXPANSION_CHARS.test(literalArg)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
if (!isWithinBase(normalizedBase, resolved)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
}
}
}
function splitCommands(command: string): ParsedCommand[] {
const tokens = tokenize(command)
const commands: ParsedCommand[] = []
let current: string[] = []
for (const token of tokens) {
if (isSeparator(token)) {
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
current = []
}
continue
}
current.push(token)
}
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
}
return commands
}
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ""
let quote: "'" | '"' | null = null
let escape = false
const flush = () => {
if (current.length > 0) {
tokens.push(current)
current = ""
}
}
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
if (escape) {
current += char
escape = false
continue
}
if (char === "\\" && quote !== "'") {
escape = true
continue
}
if (quote) {
current += char
if (char === quote) {
quote = null
}
continue
}
if (char === "'" || char === '"') {
quote = char
current += char
continue
}
if (char === " " || char === "\n" || char === "\t") {
flush()
continue
}
if (char === "|" || char === "&" || char === ";") {
flush()
const next = input[index + 1]
if ((char === "|" || char === "&") && next === char) {
tokens.push(char + next)
index += 1
} else {
tokens.push(char)
}
continue
}
current += char
}
flush()
return tokens
}
function isSeparator(token: string) {
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
}
function unquote(value: string) {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
return value.slice(1, -1)
}
}
return value
}
function isWithinBase(baseDir: string, target: string) {
const relative = path.relative(baseDir, target)
if (!relative) return true
return !relative.startsWith("..") && !path.isAbsolute(relative)
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -0,0 +1,165 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadClient(config: CodeNomadConfig) {
return {
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(event),
})
if (!response.ok) {
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
}
}
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(url, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(url, onEvent, initialBody)
}
async function connectWithRetries(url: string, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
}
return response.body
} catch (error) {
lastError = error
await delay(500 * attempt)
}
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
url: string,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
let consecutiveFailures = 0
let body: ReadableStream<Uint8Array> | null = initialBody
while (true) {
try {
if (!body) {
body = await connectWithRetries(url, 3)
}
await consumeSseBody(body, onEvent)
body = null
consecutiveFailures = 0
} catch (error) {
body = null
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
}
await delay(500 * consecutiveFailures)
}
}
}
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
separatorIndex = buffer.indexOf("\n\n")
const event = parseSseChunk(chunk)
if (event) {
onEvent(event)
}
}
}
throw new Error("SSE stream ended")
}
function parseSseChunk(chunk: string): PluginEvent | null {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) continue
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) return null
const payload = dataLines.join("\n").trim()
if (!payload) return null
try {
const parsed = JSON.parse(payload)
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
return null
}
return parsed as PluginEvent
} catch {
return null
}
}

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.4.0", "version": "0.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.4.0", "version": "0.5.1",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"commander": "^12.1.0", "commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.4.0", "version": "0.5.1",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",
@@ -16,10 +16,11 @@
"codenomad": "dist/bin.js" "codenomad": "dist/bin.js"
}, },
"scripts": { "scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui", "build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs", "prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", "prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -219,6 +219,33 @@ export interface ServerMeta {
latestRelease?: LatestReleaseInfo latestRelease?: LatestReleaseInfo
} }
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export interface BackgroundProcess {
id: string
workspaceId: string
title: string
command: string
cwd: string
status: BackgroundProcessStatus
pid?: number
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
export interface BackgroundProcessListResponse {
processes: BackgroundProcess[]
}
export interface BackgroundProcessOutputResponse {
id: string
content: string
truncated: boolean
sizeBytes: number
}
export type { export type {
Preferences, Preferences,
ModelPreference, ModelPreference,

View File

@@ -0,0 +1,438 @@
import { spawn, type ChildProcess } from "child_process"
import { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
interface ManagerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface RunningProcess {
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
workspaceId: string
}
export class BackgroundProcessManager {
private readonly running = new Map<string, RunningProcess>()
constructor(private readonly deps: ManagerDeps) {
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
}
async list(workspaceId: string): Promise<BackgroundProcess[]> {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const id = this.generateId()
const processDir = await this.ensureProcessDir(workspaceId, id)
const outputPath = path.join(processDir, OUTPUT_FILE)
const outputStream = createWriteStream(outputPath, { flags: "a" })
const child = spawn("bash", ["-c", command], {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
command,
cwd: workspace.path,
status: "running",
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
}
const exitPromise = new Promise<void>((resolve) => {
child.on("close", async (code) => {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve()
})
})
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
const now = Date.now()
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
return
}
lastPublishAt = now
this.publishUpdate(workspaceId, record)
}
child.stdout?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
child.stderr?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const record = await this.findProcess(workspaceId, processId)
if (!record) {
return null
}
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
if (record.status === "running") {
record.status = "stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
return record
}
async terminate(workspaceId: string, processId: string): Promise<void> {
const record = await this.findProcess(workspaceId, processId)
if (!record) return
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
await this.removeFromIndex(workspaceId, processId)
await this.removeProcessDir(workspaceId, processId)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId } },
})
}
async readOutput(
workspaceId: string,
processId: string,
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
}
const stats = await fs.stat(outputPath)
const sizeBytes = stats.size
const method = options.method ?? "full"
const lineCount = options.lines ?? 10
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
let content = raw
switch (method) {
case "head":
content = this.headLines(raw, lineCount)
break
case "tail":
content = this.tailLines(raw, lineCount)
break
case "grep":
if (!options.pattern) {
throw new Error("Pattern is required for grep output")
}
content = this.grepLines(raw, options.pattern)
break
default:
content = raw
}
const effectiveMaxBytes = options.maxBytes
return {
id: processId,
content,
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
sizeBytes,
}
}
async streamOutput(workspaceId: string, processId: string, reply: any) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
reply.code(404).send({ error: "Output not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const file = await fs.open(outputPath, "r")
let position = (await file.stat()).size
const tick = async () => {
const stats = await file.stat()
if (stats.size <= position) return
const length = stats.size - position
const buffer = Buffer.alloc(length)
await file.read(buffer, 0, length, position)
position = stats.size
const content = buffer.toString("utf-8")
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
}
const interval = setInterval(() => {
tick().catch((error) => {
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
})
}, 1000)
const close = () => {
clearInterval(interval)
file.close().catch(() => undefined)
reply.raw.end?.()
}
reply.raw.on("close", close)
reply.raw.on("error", close)
}
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private async waitForExit(running: RunningProcess) {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
running.child.kill("SIGKILL")
}
}, STOP_TIMEOUT_MS)
await running.exitPromise.finally(() => {
resolved = true
clearTimeout(timeout)
})
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
if (maxBytes === undefined || sizeBytes <= maxBytes) {
return await fs.readFile(outputPath, "utf-8")
}
const start = Math.max(0, sizeBytes - maxBytes)
const file = await fs.open(outputPath, "r")
const buffer = Buffer.alloc(sizeBytes - start)
await file.read(buffer, 0, buffer.length, start)
await file.close()
return buffer.toString("utf-8")
}
private headLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(0, Math.max(0, lines)).join("\n")
}
private tailLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
}
private grepLines(input: string, pattern: string): string {
let matcher: RegExp
try {
matcher = new RegExp(pattern)
} catch {
throw new Error("Invalid grep pattern")
}
return input
.split(/\r?\n/)
.filter((line) => matcher.test(line))
.join("\n")
}
private async ensureProcessDir(workspaceId: string, processId: string) {
const root = await this.ensureWorkspaceDir(workspaceId)
const processDir = path.join(root, processId)
await fs.mkdir(processDir, { recursive: true })
return processDir
}
private async ensureWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.mkdir(root, { recursive: true })
return root
}
private getOutputPath(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return []
try {
const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
records[index] = record
} else {
records.push(record)
}
await this.writeIndex(workspaceId, records)
}
private async removeFromIndex(workspaceId: string, processId: string) {
const records = await this.readIndex(workspaceId)
const next = records.filter((entry) => entry.id !== processId)
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
}
private async getIndexPath(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
}
private async removeProcessDir(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
await fs.rm(processDir, { recursive: true, force: true })
}
private async removeWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.rm(workspaceDir, { recursive: true, force: true })
}
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return 0
}
try {
const stats = await fs.stat(outputPath)
return stats.size
} catch {
return 0
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
})
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")
return `proc_${timestamp}_${random}`
}
}

View File

@@ -122,22 +122,6 @@ async function main() {
logger.info({ options }, "Starting CodeNomad CLI server") logger.info({ options }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger) const eventBus = new EventBus(eventLogger)
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
eventBus,
logger: workspaceLogger,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`, httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -150,6 +134,24 @@ async function main() {
addresses: [], addresses: [],
} }
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const releaseMonitor = startReleaseMonitor({ const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version, currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }), logger: logger.child({ component: "release-monitor" }),

View File

@@ -0,0 +1,31 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
}
return templateDir
}

View File

@@ -0,0 +1,55 @@
import type { FastifyReply } from "fastify"
import type { Logger } from "../logger"
export interface PluginOutboundEvent {
type: string
properties?: Record<string, unknown>
}
interface ClientConnection {
reply: FastifyReply
workspaceId: string
}
export class PluginChannelManager {
private readonly clients = new Set<ClientConnection>()
constructor(private readonly logger: Logger) {}
register(workspaceId: string, reply: FastifyReply) {
const connection: ClientConnection = { workspaceId, reply }
this.clients.add(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
let closed = false
const close = () => {
if (closed) return
closed = true
this.clients.delete(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
}
return { close }
}
send(workspaceId: string, event: PluginOutboundEvent) {
for (const client of this.clients) {
if (client.workspaceId !== workspaceId) continue
this.write(client.reply, event)
}
}
broadcast(event: PluginOutboundEvent) {
for (const client of this.clients) {
this.write(client.reply, event)
}
}
private write(reply: FastifyReply, event: PluginOutboundEvent) {
try {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
} catch (error) {
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
}
}
}

View File

@@ -0,0 +1,36 @@
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { PluginOutboundEvent } from "./channel"
export interface PluginInboundEvent {
type: string
properties?: Record<string, unknown>
}
interface HandlerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
switch (event.type) {
case "codenomad.pong":
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
return
default:
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
}
}
export function buildPingEvent(): PluginOutboundEvent {
return {
type: "codenomad.ping",
properties: {
ts: Date.now(),
},
}
}

View File

@@ -18,8 +18,11 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta" import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events" import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage" import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
interface HttpServerDeps { interface HttpServerDeps {
host: string host: string
@@ -100,6 +103,12 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
}) })
const backgroundProcessManager = new BackgroundProcessManager({
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
@@ -110,6 +119,8 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -0,0 +1,85 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { BackgroundProcessManager } from "../../background-processes/manager"
interface RouteDeps {
backgroundProcessManager: BackgroundProcessManager
}
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
})
const OutputQuerySchema = z.object({
method: z.enum(["full", "tail", "head", "grep"]).optional(),
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
pattern: z.string().optional(),
lines: z.coerce.number().int().positive().max(2000).optional(),
maxBytes: z.coerce.number().int().positive().optional(),
})
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
const processes = await deps.backgroundProcessManager.list(request.params.id)
return { processes }
})
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
reply.code(201)
return process
})
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stop",
async (request, reply) => {
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
if (!process) {
reply.code(404)
return { error: "Process not found" }
}
return process
},
)
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/terminate",
async (request, reply) => {
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
reply.code(204)
return undefined
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/output",
async (request, reply) => {
const query = OutputQuerySchema.parse(request.query ?? {})
const method = query.method ?? query.mode
if (method === "grep" && !query.pattern) {
reply.code(400)
return { error: "Pattern is required for grep output" }
}
try {
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
method,
pattern: query.pattern,
lines: query.lines,
maxBytes: query.maxBytes,
})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid output request" }
}
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stream",
async (request, reply) => {
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
},
)
}

View File

@@ -0,0 +1,75 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
const PluginEventSchema = z.object({
type: z.string().min(1),
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
clearInterval(heartbeat)
registration.close()
reply.raw.end?.()
}
request.raw.on("close", close)
request.raw.on("error", close)
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const suffix = (request.params["*"] as string | undefined) ?? ""
const normalized = suffix.replace(/^\/+/, "")
if (normalized === "event" && request.method === "POST") {
const parsed = PluginEventSchema.parse(request.body ?? {})
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
reply.code(204).send()
return
}
reply.code(404).send({ error: "Unknown plugin endpoint" })
}
app.all("/workspaces/:id/plugin/*", handleWildcard)
app.all("/workspaces/:id/plugin", handleWildcard)
}

View File

@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
}) })
app.post("/api/workspaces", async (request, reply) => { app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {}) try {
const workspace = await deps.workspaceManager.create(body.path, body.name) const body = WorkspaceCreateSchema.parse(request.body ?? {})
reply.code(201) const workspace = await deps.workspaceManager.create(body.path, body.name)
return workspace reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
}) })
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {

View File

@@ -1,5 +1,6 @@
import path from "path" import path from "path"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
@@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions { interface WorkspaceManagerOptions {
rootDir: string rootDir: string
@@ -16,6 +20,7 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry binaryRegistry: BinaryRegistry
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
getServerBaseUrl: () => string
} }
interface WorkspaceRecord extends WorkspaceDescriptor {} interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -23,9 +28,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager { export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>() private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) { constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
} }
list(): WorkspaceDescriptor[] { list(): WorkspaceDescriptor[] {
@@ -97,10 +104,17 @@ export class WorkspaceManager {
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {} const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
}
try { try {
const { pid, port } = await this.runtime.launch({ const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id, workspaceId: id,
folder: workspacePath, folder: workspacePath,
binaryPath: resolvedBinaryPath, binaryPath: resolvedBinaryPath,
@@ -108,6 +122,8 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid descriptor.pid = pid
descriptor.port = port descriptor.port = port
descriptor.status = "ready" descriptor.status = "ready"
@@ -233,6 +249,161 @@ export class WorkspaceManager {
return undefined return undefined
} }
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
if (latestOutput) {
throw new Error(latestOutput)
}
const reason = probeResult.reason ?? "Health check failed"
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`
try {
const response = await fetch(url)
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId) const workspace = this.workspaces.get(workspaceId)
if (!workspace) return if (!workspace) return

View File

@@ -13,7 +13,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void onExit?: (info: ProcessExitInfo) => void
} }
interface ProcessExitInfo { export interface ProcessExitInfo {
workspaceId: string workspaceId: string
code: number | null code: number | null
signal: NodeJS.Signals | null signal: NodeJS.Signals | null
@@ -30,15 +30,45 @@ export class WorkspaceRuntime {
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> { async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder) this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) } const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
// Store recent output for debugging - keep last 50 lines from each stream
const MAX_OUTPUT_LINES = 50
const recentStdout: string[] = []
const recentStderr: string[] = []
const getLastOutput = () => {
const combined: string[] = []
if (recentStderr.length > 0) {
combined.push("Error Stream")
combined.push(...recentStderr.slice(-10))
}
if (recentStdout.length > 0) {
combined.push("Output Stream")
combined.push(...recentStdout.slice(-10))
}
return combined.join("\n")
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const commandLine = [options.binaryPath, ...args].join(" ")
this.logger.info( this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath }, {
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
args,
commandLine,
env,
},
"Launching OpenCode process", "Launching OpenCode process",
) )
const child = spawn(options.binaryPath, args, { const child = spawn(options.binaryPath, args, {
@@ -83,11 +113,22 @@ export class WorkspaceRuntime {
cleanupStreams() cleanupStreams()
child.removeListener("error", handleError) child.removeListener("error", handleError)
child.removeListener("exit", handleExit) child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) { if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}` const recentOutput = getLastOutput().trim()
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason)) reject(new Error(reason))
} else { } else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop }) options.onExit?.(exitInfo)
} }
} }
@@ -96,6 +137,10 @@ export class WorkspaceRuntime {
child.removeListener("exit", handleExit) child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId) this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error) reject(error)
} }
@@ -109,18 +154,25 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? "" stdoutBuffer = lines.pop() ?? ""
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue const trimmed = line.trim()
if (!trimmed) continue
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
}
this.emitLog(options.workspaceId, "info", line) this.emitLog(options.workspaceId, "info", line)
if (!portFound) { if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) { if (portMatch) {
portFound = true portFound = true
cleanupStreams() stopWarningTimer()
child.removeListener("error", handleError) child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10) const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port }) resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
} }
} }
} }
@@ -133,7 +185,14 @@ export class WorkspaceRuntime {
stderrBuffer = lines.pop() ?? "" stderrBuffer = lines.pop() ?? ""
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue const trimmed = line.trim()
if (!trimmed) continue
recentStderr.push(trimmed)
if (recentStderr.length > MAX_OUTPUT_LINES) {
recentStderr.shift()
}
this.emitLog(options.workspaceId, "error", line) this.emitLog(options.workspaceId, "error", line)
} }
}) })

View File

@@ -1,15 +1,15 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.4.0", "version": "0.5.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev", "dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js", "dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui", "dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild", "bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build" "build": "tauri build"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.4.0", "version": "0.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,11 +12,12 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.138", "@opencode-ai/sdk": "1.1.1",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0", "lucide-solid": "^0.300.0",

View File

@@ -21,11 +21,9 @@ import {
hasInstances, hasInstances,
isSelectingFolder, isSelectingFolder,
setIsSelectingFolder, setIsSelectingFolder,
setHasInstances,
showFolderSelection, showFolderSelection,
setShowFolderSelection, setShowFolderSelection,
} from "./stores/ui" } from "./stores/ui"
import { instances as instanceStore } from "./stores/instances"
import { useConfig } from "./stores/preferences" import { useConfig } from "./stores/preferences"
import { import {
createInstance, createInstance,
@@ -65,7 +63,12 @@ const App: Component = () => {
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null) interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
@@ -105,14 +108,30 @@ const App: Component = () => {
}) })
const launchErrorPath = () => { const launchErrorPath = () => {
const value = launchErrorBinary() const value = launchError()?.binaryPath
if (!value) return "opencode" if (!value) return "opencode"
return value.trim() || "opencode" return value.trim() || "opencode"
} }
const isMissingBinaryError = (error: unknown): boolean => { const launchErrorMessage = () => launchError()?.message ?? ""
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return "Failed to launch workspace"
}
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() const normalized = message.toLowerCase()
return ( return (
normalized.includes("opencode binary not found") || normalized.includes("opencode binary not found") ||
@@ -123,7 +142,7 @@ const App: Component = () => {
) )
} }
const clearLaunchError = () => setLaunchErrorBinary(null) const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) { if (!folderPath) {
@@ -135,7 +154,6 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false) setIsAdvancedSettingsOpen(false)
@@ -144,10 +162,13 @@ const App: Component = () => {
port: instances().get(instanceId)?.port, port: instances().get(instanceId)?.port,
}) })
} catch (error) { } catch (error) {
clearLaunchError() const message = formatLaunchErrorMessage(error)
if (isMissingBinaryError(error)) { const missingBinary = isMissingBinaryMessage(message)
setLaunchErrorBinary(selectedBinary) setLaunchError({
} message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error) log.error("Failed to create instance", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
@@ -191,9 +212,6 @@ const App: Component = () => {
if (!confirmed) return if (!confirmed) return
await stopInstance(instanceId) await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
} }
async function handleNewSession(instanceId: string) { async function handleNewSession(instanceId: string) {
@@ -304,7 +322,7 @@ const App: Component = () => {
onClose={handleDisconnectedInstanceClose} onClose={handleDisconnectedInstanceClose}
/> />
<Dialog open={Boolean(launchErrorBinary())} modal> <Dialog open={Boolean(launchError())} modal>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -312,8 +330,8 @@ const App: Component = () => {
<div> <div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title> <Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words"> <Dialog.Description class="text-sm text-secondary mt-2 break-words">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from We couldn't start the selected OpenCode binary. Review the error output below or choose a different
Advanced Settings. binary from Advanced Settings.
</Dialog.Description> </Dialog.Description>
</div> </div>
@@ -322,10 +340,23 @@ const App: Component = () => {
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p> <p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div> </div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}> <Show when={launchError()?.missingBinary}>
Open Advanced Settings <button
</button> type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}> <button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close Close
</button> </button>

View File

@@ -0,0 +1,167 @@
import { Dialog } from "@kobalte/core/dialog"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
interface BackgroundProcessOutputDialogProps {
open: boolean
instanceId: string
process: BackgroundProcess | null
onClose: () => void
}
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
const [truncated, setTruncated] = createSignal(false)
const [loading, setLoading] = createSignal(false)
let ansiRenderer = createAnsiStreamRenderer()
createEffect(() => {
const process = props.process
if (!props.open || !process) {
return
}
let eventSource: EventSource | null = null
let active = true
let rawOutput = ""
const setRawOutput = (next: string) => {
rawOutput = next
setOutput(next)
}
const appendRawOutput = (chunk: string) => {
rawOutput += chunk
setOutput(rawOutput)
}
setAnsiEnabled(false)
setOutputHtml("")
setRawOutput("")
ansiRenderer.reset()
setLoading(true)
serverApi
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
.then((response) => {
if (!active) return
setRawOutput(response.content)
setTruncated(response.truncated)
const detectedAnsi = hasAnsi(response.content)
if (detectedAnsi) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(response.content))
} else {
setAnsiEnabled(false)
setOutputHtml("")
ansiRenderer.reset()
}
})
.catch(() => {
if (!active) return
setRawOutput("Failed to load output.")
setAnsiEnabled(false)
setOutputHtml("")
})
.finally(() => {
if (!active) return
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
return
}
const chunk = payload.content
const wasAnsiEnabled = ansiEnabled()
if (!wasAnsiEnabled) {
appendRawOutput(chunk)
if (hasAnsi(chunk)) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(rawOutput))
}
return
}
appendRawOutput(chunk)
const htmlChunk = ansiRenderer.render(chunk)
setOutputHtml((prev) => `${prev}${htmlChunk}`)
} catch {
// ignore parse errors
}
}
onCleanup(() => {
active = false
eventSource?.close()
})
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
<Show when={props.process}>
<span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id}
</span>
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
{props.process?.command}
</span>
</Show>
</div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
Close
</button>
</div>
<div class="flex-1 overflow-auto p-6">
<Show when={loading()}>
<p class="text-xs text-secondary">Loading output...</p>
</Show>
<Show when={!loading()}>
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
</Show>
<Show
when={ansiEnabled()}
fallback={
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
}
>
<pre
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
innerHTML={outputHtml()}
/>
</Show>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown" import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
const inlineLoadedLanguages = new Set<string>() const inlineLoadedLanguages = new Set<string>()
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
} }
const copyCode = async () => { const copyCode = async () => {
await navigator.clipboard.writeText(props.code) const success = await copyToClipboard(props.code)
setCopied(true) if (success) {
setTimeout(() => setCopied(false), 2000) setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
} }
return ( return (

View File

@@ -6,7 +6,7 @@ import { getLogger } from "../lib/logger"
const log = getLogger("session") const log = getLogger("session")
type ServiceSection = "lsp" | "mcp" type ServiceSection = "lsp" | "mcp" | "plugins"
interface InstanceServiceStatusProps { interface InstanceServiceStatusProps {
sections?: ServiceSection[] sections?: ServiceSection[]
@@ -51,20 +51,25 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
}) })
const isLoading = metadataContext?.isLoading ?? (() => false) const isLoading = metadataContext?.isLoading ?? (() => false)
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve()) const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp"]) const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp", "plugins"])
const includeLsp = createMemo(() => sections().includes("lsp")) const includeLsp = createMemo(() => sections().includes("lsp"))
const includeMcp = createMemo(() => sections().includes("mcp")) const includeMcp = createMemo(() => sections().includes("mcp"))
const includePlugins = createMemo(() => sections().includes("plugins"))
const showHeadings = () => props.showSectionHeadings !== false const showHeadings = () => props.showSectionHeadings !== false
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata) const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
const metadata = createMemo(() => metadataAccessor()) const metadata = createMemo(() => metadataAccessor())
const hasLspMetadata = () => metadata()?.lspStatus !== undefined const hasLspMetadata = () => metadata()?.lspStatus !== undefined
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
const lspServers = createMemo(() => metadata()?.lspStatus ?? []) const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined)) const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
const plugins = createMemo(() => metadata()?.plugins ?? [])
const isLspLoading = () => isLoading() || !hasLspMetadata() const isLspLoading = () => isLoading() || !hasLspMetadata()
const isMcpLoading = () => isLoading() || !hasMcpMetadata() const isMcpLoading = () => isLoading() || !hasMcpMetadata()
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({}) const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
@@ -85,9 +90,9 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
setPendingMcpAction(serverName, action) setPendingMcpAction(serverName, action)
try { try {
if (shouldEnable) { if (shouldEnable) {
await client.mcp.connect({ path: { name: serverName } }) await client.mcp.connect({ name: serverName })
} else { } else {
await client.mcp.disconnect({ path: { name: serverName } }) await client.mcp.disconnect({ name: serverName })
} }
await refreshMetadata() await refreshMetadata()
} catch (error) { } catch (error) {
@@ -213,10 +218,35 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
</section> </section>
) )
const renderPluginsSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Plugins
</div>
</Show>
<Show
when={!isPluginsLoading() && plugins().length > 0}
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
>
<div class="space-y-1.5">
<For each={plugins()}>
{(plugin) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="text-xs text-primary font-medium break-words whitespace-normal">{plugin}</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
return ( return (
<div class={props.class}> <div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show> <Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show> <Show when={includeMcp()}>{renderMcpSection()}</Show>
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
import { Component } from "solid-js" import { Component, createMemo } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { FolderOpen, X } from "lucide-solid" import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
interface InstanceTabProps { interface InstanceTabProps {
instance: Instance instance: Instance
@@ -26,6 +27,24 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
} }
const InstanceTab: Component<InstanceTabProps> = (props) => { const InstanceTab: Component<InstanceTabProps> = (props) => {
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const statusClassName = createMemo(() => {
const status = aggregatedStatus()
return status === "permission" ? "session-permission" : `session-${status}`
})
const statusTitle = createMemo(() => {
switch (aggregatedStatus()) {
case "permission":
return "Waiting on permission"
case "compacting":
return "Compacting"
case "working":
return "Working"
default:
return "Idle"
}
})
return ( return (
<div class="group"> <div class="group">
<button <button
@@ -40,7 +59,18 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
{props.instance.folder.split("/").pop() || props.instance.folder} {props.instance.folder.split("/").pop() || props.instance.folder}
</span> </span>
<span <span
class="tab-close ml-auto" class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle()}
aria-label={`Instance status: ${statusTitle()}`}
>
{aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
</span>
<span
class="tab-close"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
props.onClose() props.onClose()

View File

@@ -12,7 +12,7 @@ import {
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { ChevronDown } from "lucide-solid" import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar" import AppBar from "@suid/material/AppBar"
import Box from "@suid/material/Box" import Box from "@suid/material/Box"
import Divider from "@suid/material/Divider" import Divider from "@suid/material/Divider"
@@ -28,6 +28,7 @@ import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands" import type { Command } from "../../lib/commands"
import type { BackgroundProcess } from "../../../../server/src/api-types"
import { import {
activeParentSessionId, activeParentSessionId,
activeSessionId as activeSessionMap, activeSessionId as activeSessionMap,
@@ -56,6 +57,9 @@ import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { import {
SESSION_SIDEBAR_EVENT, SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction, type SessionSidebarRequestAction,
@@ -128,7 +132,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp"]) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan",
"background-processes",
"mcp",
"lsp",
"plugins",
])
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
@@ -152,6 +164,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
persistPinState(side, value) persistPinState(side, value)
} }
createEffect(() => {
const instanceId = props.instance.id
loadBackgroundProcesses(instanceId).catch((error) => {
log.warn("Failed to load background processes", error)
})
})
createEffect(() => { createEffect(() => {
switch (layoutMode()) { switch (layoutMode()) {
case "desktop": { case "desktop": {
@@ -314,6 +333,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return state return state
}) })
const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id))
const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => { const connectionStatusClass = () => {
const status = connectionStatus() const status = connectionStatus()
@@ -326,6 +347,32 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
showCommandPalette(props.instance.id) showCommandPalette(props.instance.id)
} }
const openBackgroundOutput = (process: BackgroundProcess) => {
setSelectedBackgroundProcess(process)
setShowBackgroundOutput(true)
}
const closeBackgroundOutput = () => {
setShowBackgroundOutput(false)
setSelectedBackgroundProcess(null)
}
const stopBackgroundProcess = async (processId: string) => {
try {
await serverApi.stopBackgroundProcess(props.instance.id, processId)
} catch (error) {
log.warn("Failed to stop background process", error)
}
}
const terminateBackgroundProcess = async (processId: string) => {
try {
await serverApi.terminateBackgroundProcess(props.instance.id, processId)
} catch (error) {
log.warn("Failed to terminate background process", error)
}
}
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
@@ -853,18 +900,73 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} /> return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} />
} }
const renderBackgroundProcesses = () => {
const processes = backgroundProcessList()
if (processes.length === 0) {
return <p class="text-xs text-secondary">No background processes.</p>
}
return (
<div class="flex flex-col gap-2">
<For each={processes}>
{(process) => (
<div class="rounded-md border border-base bg-surface-secondary p-2 flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-primary">{process.title}</span>
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
<span>Status: {process.status}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span>
</Show>
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => openBackgroundOutput(process)}
aria-label="Output"
title="Output"
>
<TerminalSquare class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"}
onClick={() => stopBackgroundProcess(process.id)}
aria-label="Stop"
title="Stop"
>
<XOctagon class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => terminateBackgroundProcess(process.id)}
aria-label="Terminate"
title="Terminate"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
)}
</For>
</div>
)
}
const sections = [ const sections = [
{ {
id: "lsp", id: "plan",
label: "LSP Servers", label: "Plan",
render: () => ( render: renderPlanSectionContent,
<InstanceServiceStatus },
initialInstance={props.instance} {
sections={["lsp"]} id: "background-processes",
showSectionHeadings={false} label: "Background Shells",
class="space-y-2" render: renderBackgroundProcesses,
/>
),
}, },
{ {
id: "mcp", id: "mcp",
@@ -879,9 +981,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
), ),
}, },
{ {
id: "plan", id: "lsp",
label: "Plan", label: "LSP Servers",
render: renderPlanSectionContent, render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "plugins",
label: "Plugins",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["plugins"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
}, },
] ]
@@ -1301,6 +1422,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
commands={instancePaletteCommands()} commands={instancePaletteCommands()}
onExecute={props.onExecuteCommand} onExecute={props.onExecuteCommand}
/> />
<BackgroundProcessOutputDialog
open={showBackgroundOutput()}
instanceId={props.instance.id}
process={selectedBackgroundProcess()}
onClose={closeBackgroundOutput}
/>
</> </>
) )
} }

View File

@@ -1,17 +1,32 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
const markdownRenderCache = new Map<string, RenderCache>() function hashText(value: string): string {
let hash = 2166136261
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16)
}
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) { function resolvePartVersion(part: TextPart, text: string): string {
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}` if (typeof part.version === "number") {
return String(part.version)
}
return `text-${hashText(text)}`
} }
interface MarkdownProps { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string
sessionId?: string
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
@@ -27,33 +42,64 @@ export function Markdown(props: MarkdownProps) {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
} }
createEffect(async () => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText) const text = decodeHtmlEntities(rawText)
const dark = Boolean(props.isDark) const themeKey = Boolean(props.isDark) ? "dark" : "light"
const themeKey = dark ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__" const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled) if (!partId) {
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(part, text)
return { part, text, themeKey, highlightEnabled, partId, version }
})
const cacheHandle = useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: "markdown",
cacheId: () => {
const { partId, themeKey, highlightEnabled } = resolved()
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
},
version: () => resolved().version,
})
createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved()
latestRequestedText = text latestRequestedText = text
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === themeKey && cache.mode === version
}
const localCache = part.renderCache const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
return return
} }
const globalCache = markdownRenderCache.get(cacheKey) const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && globalCache.text === text) { if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html) setHtml(globalCache.html)
part.renderCache = globalCache part.renderCache = globalCache
notifyRendered() notifyRendered()
return return
} }
const commitCacheEntry = (renderedHtml: string) => {
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
if (!highlightEnabled) { if (!highlightEnabled) {
part.renderCache = undefined part.renderCache = undefined
@@ -61,20 +107,12 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true }) const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey } commitCacheEntry(rendered)
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey } commitCacheEntry(text)
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} }
return return
@@ -82,22 +120,13 @@ export function Markdown(props: MarkdownProps) {
try { try {
const rendered = await renderMarkdown(text) const rendered = await renderMarkdown(text)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey } commitCacheEntry(rendered)
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey } commitCacheEntry(text)
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} }
}) })
@@ -112,13 +141,20 @@ export function Markdown(props: MarkdownProps) {
const code = copyButton.getAttribute("data-code") const code = copyButton.getAttribute("data-code")
if (code) { if (code) {
const decodedCode = decodeURIComponent(code) const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode) const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text") const copyText = copyButton.querySelector(".copy-text")
if (copyText) { if (copyText) {
copyText.textContent = "Copied!" if (success) {
setTimeout(() => { copyText.textContent = "Copied!"
copyText.textContent = "Copy" setTimeout(() => {
}, 2000) copyText.textContent = "Copy"
}, 2000)
} else {
copyText.textContent = "Failed"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
}
} }
} }
} }
@@ -126,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
containerRef?.addEventListener("click", handleClick) containerRef?.addEventListener("click", handleClick)
// Register listener for language loading completion
const cleanupLanguageListener = onLanguagesLoaded(async () => { const cleanupLanguageListener = onLanguagesLoaded(async () => {
if (props.disableHighlight) { if (props.disableHighlight) {
return return
} }
const part = props.part const { part, text, themeKey, version } = resolved()
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
if (latestRequestedText !== text) { if (latestRequestedText !== text) {
return return
@@ -143,9 +176,10 @@ export function Markdown(props: MarkdownProps) {
try { try {
const rendered = await renderMarkdown(text) const rendered = await renderMarkdown(text)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered) setHtml(rendered)
const themeKey = Boolean(props.isDark) ? "dark" : "light" part.renderCache = cacheEntry
part.renderCache = { text, html: rendered, theme: themeKey } cacheHandle.set(cacheEntry)
notifyRendered() notifyRendered()
} }
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -192,7 +193,15 @@ type ReasoningDisplayItem = {
defaultExpanded: boolean defaultExpanded: boolean
} }
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem type CompactionDisplayItem = {
type: "compaction"
key: string
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
interface MessageDisplayBlock { interface MessageDisplayBlock {
record: MessageRecord record: MessageRecord
@@ -330,6 +339,21 @@ export default function MessageBlock(props: MessageBlockProps) {
return return
} }
if (part.type === "compaction") {
flushContent()
const key = `${current.id}:${part.id ?? partIndex}:compaction`
const isAuto = Boolean((part as any)?.auto)
items.push({
type: "compaction",
key,
part,
messageInfo: info,
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
})
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
return
}
if (part.type === "step-start") { if (part.type === "step-start") {
flushContent() flushContent()
return return
@@ -453,7 +477,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</div> </div>
<ToolCall <ToolCall
toolCall={toolItem.toolPart} toolCall={toolItem.toolPart}
toolCallId={toolItem.key} toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId} messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion} messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion} partVersion={toolItem.partVersion}
@@ -477,6 +501,9 @@ export default function MessageBlock(props: MessageBlockProps) {
borderColor={(item as StepDisplayItem).accentColor} borderColor={(item as StepDisplayItem).accentColor}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}>
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
</Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
<ReasoningCard <ReasoningCard
part={(item as ReasoningDisplayItem).part} part={(item as ReasoningDisplayItem).part}
@@ -505,6 +532,29 @@ interface StepCardProps {
borderColor?: string borderColor?: string
} }
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
return (
<div
class={containerClass()}
style={{ "border-left": `4px solid ${borderColor()}` }}
role="status"
aria-label="Session compaction"
>
<div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
</div>
</div>
)
}
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const timestamp = () => { const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()

View File

@@ -3,6 +3,7 @@ import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -15,9 +16,9 @@ interface MessageItemProps {
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
@@ -155,8 +156,8 @@ interface MessageItemProps {
const handleCopy = async () => { const handleCopy = async () => {
const content = getRawContent() const content = getRawContent()
if (!content) return if (!content) return
await navigator.clipboard.writeText(content) const success = await copyToClipboard(content)
setCopied(true) setCopied(success)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }

View File

@@ -102,6 +102,8 @@ interface MessagePartProps {
> >
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered} onRendered={props.onRendered}

View File

@@ -5,9 +5,9 @@ import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
export type TimelineSegmentType = "user" | "assistant" | "tool" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
export interface TimelineSegment { export interface TimelineSegment {
id: string id: string
@@ -16,6 +16,7 @@ export interface TimelineSegment {
label: string label: string
tooltip: string tooltip: string
shortLabel?: string shortLabel?: string
variant?: "auto" | "manual"
} }
interface MessageTimelineProps { interface MessageTimelineProps {
@@ -31,6 +32,7 @@ const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
user: "You", user: "You",
assistant: "Asst", assistant: "Asst",
tool: "Tool", tool: "Tool",
compaction: "Compaction",
} }
const TOOL_FALLBACK_LABEL = "Tool Call" const TOOL_FALLBACK_LABEL = "Tool Call"
@@ -215,6 +217,21 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
continue continue
} }
if (part.type === "compaction") {
flushPending()
const isAuto = Boolean((part as any)?.auto)
result.push({
id: `${record.id}:${segmentIndex}`,
messageId: record.id,
type: "compaction",
label: SEGMENT_LABELS.compaction,
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
variant: isAuto ? "auto" : "manual",
})
segmentIndex += 1
continue
}
if (part.type === "step-start" || part.type === "step-finish") { if (part.type === "step-start" || part.type === "step-finish") {
continue continue
} }
@@ -343,20 +360,26 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
onCleanup(() => buttonRefs.delete(segment.id)) onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId const isActive = () => props.activeMessageId === segment.messageId
const isHidden = () => segment.type === "tool" && !(showTools() || isActive()) const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
return segment.shortLabel ?? getToolIcon("tool") return segment.shortLabel ?? getToolIcon("tool")
} }
if (segment.type === "user") { if (segment.type === "compaction") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" /> return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
} }
return <BotIcon class="message-timeline-icon" aria-hidden="true" /> if (segment.type === "user") {
} return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined} aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)} onClick={() => props.onSegmentClick?.(segment)}

View File

@@ -1,7 +1,7 @@
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js" import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
import type { Session, SessionStatus } from "../types/session" import type { Session, SessionStatus } from "../types/session"
import { getSessionStatus } from "../stores/session-status" import { getSessionStatus } from "../stores/session-status"
import { MessageSquare, Info, X, Copy, Trash2, Pencil } from "lucide-solid" import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd" import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
@@ -10,6 +10,7 @@ import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading, renameSession } from "../stores/sessions" import { deleteSession, loading, renameSession } from "../stores/sessions"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
@@ -74,12 +75,12 @@ const SessionList: Component<SessionListProps> = (props) => {
event.stopPropagation() event.stopPropagation()
try { try {
if (typeof navigator === "undefined" || !navigator.clipboard) { const success = await copyToClipboard(sessionId)
throw new Error("Clipboard API unavailable") if (success) {
showToastNotification({ message: "Session ID copied", variant: "success" })
} else {
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
} }
await navigator.clipboard.writeText(sessionId)
showToastNotification({ message: "Session ID copied", variant: "success" })
} catch (error) { } catch (error) {
log.error(`Failed to copy session ID ${sessionId}:`, error) log.error(`Failed to copy session ID ${sessionId}:`, error)
showToastNotification({ message: "Unable to copy session ID", variant: "error" }) showToastNotification({ message: "Unable to copy session ID", variant: "error" })
@@ -171,7 +172,11 @@ const SessionList: Component<SessionListProps> = (props) => {
</div> </div>
<div class="session-item-row session-item-meta"> <div class="session-item-row session-item-meta">
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
<span class="status-dot" /> {pendingPermission() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{statusText()} {statusText()}
</span> </span>
<div class="session-item-actions"> <div class="session-item-actions">

View File

@@ -10,6 +10,7 @@ import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setAc
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
const log = getLogger("session") const log = getLogger("session")
@@ -39,6 +40,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return getSessionBusyStatus(props.instanceId, currentSession.id) return getSessionBusyStatus(props.instanceId, currentSession.id)
}) })
let scrollToBottomHandle: (() => void) | undefined let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
function scheduleScrollToBottom() { function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return if (!scrollToBottomHandle) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -121,14 +123,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!instance || !instance.client) return if (!instance || !instance.client) return
try { try {
await instance.client.session.revert({ await requestData(
path: { id: props.sessionId }, instance.client.session.revert({
body: { messageID: messageId }, sessionID: props.sessionId,
}) messageID: messageId,
}),
"session.revert",
)
const restoredText = getUserMessageText(messageId) const restoredText = getUserMessageText(messageId)
if (restoredText) { if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) { if (textarea) {
textarea.value = restoredText textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.dispatchEvent(new Event("input", { bubbles: true }))
@@ -164,7 +169,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error)) await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
if (restoredText) { if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) { if (textarea) {
textarea.value = restoredText textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.dispatchEvent(new Event("input", { bubbles: true }))
@@ -194,7 +199,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const activeSession = sessionAccessor() const activeSession = sessionAccessor()
if (!activeSession) return null if (!activeSession) return null
return ( return (
<div class="session-view"> <div ref={rootRef} class="session-view">
<MessageSection <MessageSection
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={activeSession.id} sessionId={activeSession.id}

View File

@@ -7,12 +7,14 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences" import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances" import { sendPermissionResponse } from "../stores/instances"
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { resolveToolRenderer } from "./tool-call/renderers" import { resolveToolRenderer } from "./tool-call/renderers"
import type { import type {
DiffPayload, DiffPayload,
DiffRenderOptions, DiffRenderOptions,
MarkdownRenderOptions, MarkdownRenderOptions,
AnsiRenderOptions,
ToolCallPart, ToolCallPart,
ToolRendererContext, ToolRendererContext,
ToolScrollHelpers, ToolScrollHelpers,
@@ -20,11 +22,15 @@ import type {
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title" import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { escapeHtml } from "../lib/markdown"
const log = getLogger("session") const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk").ToolState type ToolState = import("@opencode-ai/sdk").ToolState
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600 const TOOL_SCROLL_INTENT_WINDOW_MS = 600
@@ -217,7 +223,13 @@ export default function ToolCall(props: ToolCallProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const toolCallMemo = createMemo(() => props.toolCall) const toolCallMemo = createMemo(() => props.toolCall)
const toolName = createMemo(() => toolCallMemo()?.tool || "") const toolName = createMemo(() => toolCallMemo()?.tool || "")
const toolCallIdentifier = createMemo(() => toolCallMemo()?.callID || props.toolCallId || toolCallMemo()?.id || "") const toolCallIdentifier = createMemo(() => {
const partId = toolCallMemo()?.id
if (!partId) {
throw new Error("Tool call requires a part id")
}
return partId
})
const toolState = createMemo(() => toolCallMemo()?.state) const toolState = createMemo(() => toolCallMemo()?.state)
const cacheContext = createMemo(() => ({ const cacheContext = createMemo(() => ({
@@ -228,21 +240,36 @@ export default function ToolCall(props: ToolCallProps) {
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const createVariantCache = (variant: string) => const cacheVersion = createMemo(() => {
if (typeof props.partVersion === "number") {
return String(props.partVersion)
}
if (typeof props.messageVersion === "number") {
return String(props.messageVersion)
}
return "noversion"
})
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
useGlobalCache({ useGlobalCache({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: TOOL_CALL_CACHE_SCOPE, scope: TOOL_CALL_CACHE_SCOPE,
key: () => { cacheId: () => {
const context = cacheContext() const context = cacheContext()
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant) const resolvedVariant = typeof variant === "function" ? variant() : variant
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant)
}, },
version: () => (version ? version() : cacheVersion()),
}) })
const diffCache = createVariantCache("diff") const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff") const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown") const ansiRunningCache = createVariantCache("ansi-running", () => "running")
const ansiFinalCache = createVariantCache("ansi-final")
const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = ""
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier())) const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
const pendingPermission = createMemo(() => { const pendingPermission = createMemo(() => {
const state = permissionState() const state = permissionState()
@@ -619,6 +646,75 @@ export default function ToolCall(props: ToolCallProps) {
) )
} }
function renderAnsiContent(options: AnsiRenderOptions) {
if (!options.content) {
return null
}
const size = options.size || "default"
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
const cached = cacheHandle.get<AnsiRenderCache>()
const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined
const isRunningVariant = options.variant === "running"
let nextCache: AnsiRenderCache
if (isRunningVariant) {
const content = options.content
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) {
const detectedAnsi = hasAnsi(content)
if (detectedAnsi) {
runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true }
} else {
runningAnsiRenderer.reset()
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
}
} else {
const delta = content.slice(cached.text.length)
if (delta.length === 0) {
nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) {
runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true }
} else if (cached.hasAnsi) {
const htmlChunk = runningAnsiRenderer.render(delta)
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
} else {
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
}
}
runningAnsiSource = nextCache.text
cacheHandle.set(nextCache)
} else {
if (cached && cached.text === options.content) {
nextCache = { ...cached, mode }
} else {
const detectedAnsi = hasAnsi(options.content)
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
cacheHandle.set(nextCache)
}
}
if (options.requireAnsi && !nextCache.hasAnsi) {
return null
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{scrollHelpers.renderSentinel()}
</div>
)
}
function renderMarkdownContent(options: MarkdownRenderOptions) { function renderMarkdownContent(options: MarkdownRenderOptions) {
if (!options.content) { if (!options.content) {
return null return null
@@ -639,14 +735,13 @@ export default function ToolCall(props: ToolCallProps) {
) )
} }
const markdownPart: TextPart = { type: "text", text: options.content } const partId = toolCallMemo()?.id
const cached = markdownCache.get<RenderCache>() if (!partId) {
if (cached) { throw new Error("Tool call markdown requires a part id")
markdownPart.renderCache = cached
} }
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
const handleMarkdownRendered = () => { const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered() handleScrollRendered()
props.onContentRendered?.() props.onContentRendered?.()
} }
@@ -655,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> <div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown <Markdown
part={markdownPart} part={markdownPart}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
disableHighlight={disableHighlight} disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered} onRendered={handleMarkdownRendered}
@@ -675,6 +772,7 @@ export default function ToolCall(props: ToolCallProps) {
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent, renderMarkdown: renderMarkdownContent,
renderAnsi: renderAnsiContent,
renderDiff: renderDiffContent, renderDiff: renderDiffContent,
scrollHelpers, scrollHelpers,
} }
@@ -741,7 +839,7 @@ export default function ToolCall(props: ToolCallProps) {
setPermissionSubmitting(true) setPermissionSubmitting(true)
setPermissionError(null) setPermissionError(null)
try { try {
const sessionId = permission.sessionID || props.sessionId const sessionId = getPermissionSessionId(permission) || props.sessionId
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (error) { } catch (error) {
log.error("Failed to send permission response", error) log.error("Failed to send permission response", error)
@@ -786,11 +884,11 @@ export default function ToolCall(props: ToolCallProps) {
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}> <div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header"> <div class="tool-call-permission-header">
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span> <span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-type">{permission.type}</span> <span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
</div> </div>
<div class="tool-call-permission-body"> <div class="tool-call-permission-body">
<div class="tool-call-permission-title"> <div class="tool-call-permission-title">
<code>{permission.title}</code> <code>{getPermissionDisplayTitle(permission)}</code>
</div> </div>
<Show when={diffPayload}> <Show when={diffPayload}>
{(payload) => ( {(payload) => (

View File

@@ -20,7 +20,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms` const timeoutLabel = `${timeout}ms`
return `${baseTitle} · Timeout: ${timeoutLabel}` return `${baseTitle} · Timeout: ${timeoutLabel}`
}, },
renderBody({ toolState, renderMarkdown }) { renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState() const state = toolState()
if (!state || state.status === "pending") return null if (!state || state.status === "pending") return null
@@ -36,9 +36,19 @@ export const bashRenderer: ToolRenderer = {
const parts = [command, outputResult?.text].filter(Boolean) const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null if (parts.length === 0) return null
const content = ensureMarkdownContent(parts.join("\n"), "bash", true) const joined = parts.join("\n")
if (state.status === "running") {
return renderAnsi({ content: joined, variant: "running" })
}
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
if (ansiBody) {
return ansiBody
}
const content = ensureMarkdownContent(joined, "bash", true)
if (!content) return null if (!content) return null
return renderMarkdown({ content, disableHighlight: state.status === "running" }) return renderMarkdown({ content })
}, },
} }

View File

@@ -48,6 +48,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const messageVersionAccessor = () => undefined const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined const partVersionAccessor = () => undefined
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null const renderDiff: ToolRendererContext["renderDiff"] = () => null
return { return {
@@ -57,6 +58,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown, renderMarkdown,
renderAnsi,
renderDiff, renderDiff,
scrollHelpers: undefined, scrollHelpers: undefined,
} }

View File

@@ -15,6 +15,13 @@ export interface MarkdownRenderOptions {
disableHighlight?: boolean disableHighlight?: boolean
} }
export interface AnsiRenderOptions {
content: string
size?: "default" | "large"
requireAnsi?: boolean
variant?: "running" | "final"
}
export interface DiffRenderOptions { export interface DiffRenderOptions {
variant?: string variant?: string
disableScrollTracking?: boolean disableScrollTracking?: boolean
@@ -34,6 +41,7 @@ export interface ToolRendererContext {
messageVersion?: Accessor<number | undefined> messageVersion?: Accessor<number | undefined>
partVersion?: Accessor<number | undefined> partVersion?: Accessor<number | undefined>
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
renderAnsi(options: AnsiRenderOptions): JSXElement | null
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
scrollHelpers?: ToolScrollHelpers scrollHelpers?: ToolScrollHelpers
} }

View File

@@ -1,6 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")

136
packages/ui/src/lib/ansi.ts Normal file
View File

@@ -0,0 +1,136 @@
import { createAnsiSequenceParser, createColorPalette } from "ansi-sequence-parser"
const ESC_CHAR = "\u001b"
const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/
const ANSI_ESCAPE_PATTERN = /\u001b/
const colorPalette = createColorPalette()
export function hasAnsi(text: string): boolean {
const normalized = normalizeAnsiText(text)
return ANSI_ESCAPE_PATTERN.test(normalized)
}
export function ansiToHtml(text: string): string {
const normalized = normalizeAnsiText(text)
const parser = createAnsiSequenceParser()
const tokens = parser.parse(normalized)
return tokensToHtml(tokens)
}
export interface AnsiStreamRenderer {
reset: () => void
render: (chunk: string) => string
}
export function createAnsiStreamRenderer(): AnsiStreamRenderer {
let parser = createAnsiSequenceParser()
return {
reset() {
parser = createAnsiSequenceParser()
},
render(chunk: string) {
const normalized = normalizeAnsiText(chunk)
const tokens = parser.parse(normalized)
return tokensToHtml(tokens)
},
}
}
function normalizeAnsiText(text: string): string {
if (!ANSI_LITERAL_PATTERN.test(text)) {
return text
}
return text
.replace(/\\u001b/gi, ESC_CHAR)
.replace(/\\x1b/gi, ESC_CHAR)
.replace(/\\033/g, ESC_CHAR)
}
function tokensToHtml(tokens: { value: string; foreground: unknown; background: unknown; decorations: Set<string> }[]): string {
let html = ""
for (const token of tokens) {
if (!token.value) {
continue
}
const styles = buildTokenStyles(token)
const escaped = escapeHtml(token.value)
if (!styles) {
html += escaped
continue
}
html += `<span style="${styles}">${escaped}</span>`
}
return html
}
function buildTokenStyles(token: { foreground: any; background: any; decorations: Set<string> }): string | null {
const decorations = token.decorations
let foreground = token.foreground ? colorPalette.value(token.foreground) : null
let background = token.background ? colorPalette.value(token.background) : null
if (decorations.has("reverse")) {
const swapped = foreground
foreground = background
background = swapped
}
const styles: string[] = []
if (foreground) {
styles.push(`color: ${foreground}`)
}
if (background) {
styles.push(`background-color: ${background}`)
}
if (decorations.has("bold")) {
styles.push("font-weight: 600")
}
if (decorations.has("dim")) {
styles.push("opacity: 0.7")
}
if (decorations.has("italic")) {
styles.push("font-style: italic")
}
const lines: string[] = []
if (decorations.has("underline")) {
lines.push("underline")
}
if (decorations.has("strikethrough")) {
lines.push("line-through")
}
if (decorations.has("overline")) {
lines.push("overline")
}
if (lines.length > 0) {
styles.push(`text-decoration-line: ${lines.join(" ")}`)
}
if (decorations.has("hidden")) {
styles.push("color: transparent")
styles.push("background-color: transparent")
}
return styles.length > 0 ? styles.join("; ") : null
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;")
}

View File

@@ -1,5 +1,8 @@
import type { import type {
AppConfig, AppConfig,
BackgroundProcess,
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryCreateRequest, BinaryCreateRequest,
BinaryListResponse, BinaryListResponse,
BinaryUpdateRequest, BinaryUpdateRequest,
@@ -28,6 +31,12 @@ const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
export const CODENOMAD_API_BASE = API_BASE export const CODENOMAD_API_BASE = API_BASE
export function buildBackgroundProcessStreamUrl(instanceId: string, processId: string): string {
const encodedInstanceId = encodeURIComponent(instanceId)
const encodedProcessId = encodeURIComponent(processId)
return buildAbsoluteUrl(`/workspaces/${encodedInstanceId}/plugin/background-processes/${encodedProcessId}/stream`)
}
function buildEventsUrl(base: string | undefined, path: string): string { function buildEventsUrl(base: string | undefined, path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) { if (path.startsWith("http://") || path.startsWith("https://")) {
return path return path
@@ -39,9 +48,41 @@ function buildEventsUrl(base: string | undefined, path: string): string {
return path return path
} }
function buildAbsoluteUrl(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
if (!API_BASE) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${API_BASE}${normalized}`
}
const httpLogger = getLogger("api") const httpLogger = getLogger("api")
const sseLogger = getLogger("sse") const sseLogger = getLogger("sse")
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}
function logHttp(message: string, context?: Record<string, unknown>) { function logHttp(message: string, context?: Record<string, unknown>) {
if (context) { if (context) {
httpLogger.info(message, context) httpLogger.info(message, context)
@@ -52,9 +93,9 @@ function logHttp(message: string, context?: Record<string, unknown>) {
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers: HeadersInit = { const headers = normalizeHeaders(init?.headers)
"Content-Type": "application/json", if (init?.body !== undefined) {
...(init?.headers ?? {}), headers["Content-Type"] = "application/json"
} }
const method = (init?.method ?? "GET").toUpperCase() const method = (init?.method ?? "GET").toUpperCase()
@@ -186,6 +227,47 @@ export const serverApi = {
deleteInstanceData(id: string): Promise<void> { deleteInstanceData(id: string): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" }) return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
}, },
listBackgroundProcesses(instanceId: string): Promise<BackgroundProcessListResponse> {
return request<BackgroundProcessListResponse>(
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes`,
)
},
stopBackgroundProcess(instanceId: string, processId: string): Promise<BackgroundProcess> {
return request<BackgroundProcess>(
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/stop`,
{ method: "POST" },
)
},
terminateBackgroundProcess(instanceId: string, processId: string): Promise<void> {
return request(
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/terminate`,
{ method: "POST" },
)
},
fetchBackgroundProcessOutput(
instanceId: string,
processId: string,
options?: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
): Promise<BackgroundProcessOutputResponse> {
const params = new URLSearchParams()
if (options?.method) {
params.set("method", options.method)
}
if (options?.pattern) {
params.set("pattern", options.pattern)
}
if (options?.lines) {
params.set("lines", String(options.lines))
}
if (options?.maxBytes !== undefined) {
params.set("maxBytes", String(options.maxBytes))
}
const query = params.toString()
const suffix = query ? `?${query}` : ""
return request<BackgroundProcessOutputResponse>(
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
)
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
sseLogger.info(`Connecting to ${EVENTS_URL}`) sseLogger.info(`Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL) const source = new EventSource(EVENTS_URL)

View File

@@ -0,0 +1,61 @@
/**
* Clipboard utility with fallback for non-secure contexts
* The modern Clipboard API requires HTTPS or localhost, but document.execCommand
* works in HTTP contexts as a fallback.
*/
import { getLogger } from "./logger"
const log = getLogger("actions")
/**
* Copy text to clipboard with fallback for non-secure contexts
* @param text - The text to copy
* @returns Promise<boolean> - true if successful, false if failed
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
// Try modern Clipboard API first (requires secure context)
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
log.info("Copied text using Clipboard API")
return true
}
} catch (error) {
log.warn("Clipboard API failed, trying fallback:", error)
}
// Fallback for non-secure contexts (HTTP) using document.execCommand
try {
if (typeof document === "undefined") {
log.error("Document not available for clipboard fallback")
return false
}
// Create temporary textarea element
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "-9999px"
textArea.style.opacity = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const success = document.execCommand("copy")
document.body.removeChild(textArea)
if (success) {
log.info("Copied text using execCommand fallback")
return true
} else {
log.error("execCommand copy failed")
return false
}
} catch (error) {
log.error("Clipboard fallback failed:", error)
return false
}
}

View File

@@ -5,10 +5,16 @@ export interface CacheEntryBaseParams {
} }
export interface CacheEntryParams extends CacheEntryBaseParams { export interface CacheEntryParams extends CacheEntryBaseParams {
key: string cacheId: string
version: string
} }
type CacheValueMap = Map<string, unknown> type VersionedCacheEntry = {
version: string
value: unknown
}
type CacheValueMap = Map<string, VersionedCacheEntry>
type CacheScopeMap = Map<string, CacheValueMap> type CacheScopeMap = Map<string, CacheValueMap>
type CacheSessionMap = Map<string, CacheScopeMap> type CacheSessionMap = Map<string, CacheScopeMap>
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
if (value === undefined) { if (value === undefined) {
const existingMap = getScopeValueMap(params, false) const existingMap = getScopeValueMap(params, false)
existingMap?.delete(params.key) existingMap?.delete(params.cacheId)
cleanupHierarchy(instanceKey, sessionKey, params.scope) cleanupHierarchy(instanceKey, sessionKey, params.scope)
return return
} }
const scopeEntries = getScopeValueMap(params, true) const scopeEntries = getScopeValueMap(params, true)
scopeEntries?.set(params.key, value) scopeEntries?.set(params.cacheId, { version: params.version, value })
} }
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined { export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
const scopeEntries = getScopeValueMap(params, false) const scopeEntries = getScopeValueMap(params, false)
return scopeEntries?.get(params.key) as T | undefined const entry = scopeEntries?.get(params.cacheId)
if (!entry || entry.version !== params.version) {
return undefined
}
return entry.value as T
} }
export function clearCacheScope(params: CacheEntryBaseParams): void { export function clearCacheScope(params: CacheEntryBaseParams): void {

View File

@@ -45,11 +45,15 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
registerNavigationShortcuts() registerNavigationShortcuts()
registerInputShortcuts( registerInputShortcuts(
() => { () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = document.querySelector(
".session-cache-pane[aria-hidden=\"false\"] .prompt-input",
) as HTMLTextAreaElement
if (textarea) textarea.value = "" if (textarea) textarea.value = ""
}, },
() => { () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = document.querySelector(
".session-cache-pane[aria-hidden=\"false\"] .prompt-input",
) as HTMLTextAreaElement
textarea?.focus() textarea?.focus()
}, },
) )

View File

@@ -11,13 +11,13 @@ import {
getSessions, getSessions,
setActiveSession, setActiveSession,
} from "../../stores/sessions" } from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types" import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus" import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state" import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events" import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions") const log = getLogger("actions")
@@ -240,16 +240,15 @@ export function useCommands(options: UseCommandsOptions) {
if (!session) return if (!session) return
try { try {
setSessionCompactionState(instance.id, sessionId, true) await requestData(
await instance.client.session.summarize({ instance.client.session.summarize({
path: { id: sessionId }, sessionID: sessionId,
body: {
providerID: session.model.providerId, providerID: session.model.providerId,
modelID: session.model.modelId, modelID: session.model.modelId,
}, }),
}) "session.summarize",
)
} catch (error) { } catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error) log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session" const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, { showAlertDialog(`Compact failed: ${message}`, {
@@ -261,6 +260,22 @@ export function useCommands(options: UseCommandsOptions) {
}, },
}) })
function escapeCss(value: string) {
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
return (CSS as any).escape(value)
}
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
}
function findVisiblePromptTextarea(sessionId?: string): HTMLTextAreaElement | null {
if (typeof document === "undefined") return null
const base = ".session-cache-pane[aria-hidden=\"false\"]"
const selector = sessionId
? `${base}[data-session-id=\"${escapeCss(sessionId)}\"] .prompt-input`
: `${base} .prompt-input`
return document.querySelector(selector) as HTMLTextAreaElement | null
}
commandRegistry.register({ commandRegistry.register({
id: "undo", id: "undo",
label: "Undo Last Message", label: "Undo Last Message",
@@ -316,10 +331,13 @@ export function useCommands(options: UseCommandsOptions) {
} }
try { try {
await instance.client.session.revert({ await requestData(
path: { id: sessionId }, instance.client.session.revert({
body: { messageID }, sessionID: sessionId,
}) messageID,
}),
"session.revert",
)
if (!restoredText) { if (!restoredText) {
const fallbackRecord = store.getMessage(messageID) const fallbackRecord = store.getMessage(messageID)
@@ -327,7 +345,7 @@ export function useCommands(options: UseCommandsOptions) {
} }
if (restoredText) { if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = findVisiblePromptTextarea(sessionId)
if (textarea) { if (textarea) {
textarea.value = restoredText textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.dispatchEvent(new Event("input", { bubbles: true }))
@@ -381,7 +399,7 @@ export function useCommands(options: UseCommandsOptions) {
keywords: ["clear", "reset"], keywords: ["clear", "reset"],
shortcut: { key: "K", meta: true }, shortcut: { key: "K", meta: true },
action: () => { action: () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement const textarea = findVisiblePromptTextarea()
if (textarea) textarea.value = "" if (textarea) textarea.value = ""
}, },
}) })

View File

@@ -18,8 +18,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
const instanceId = normalizeId(resolveValue(params.instanceId)) const instanceId = normalizeId(resolveValue(params.instanceId))
const sessionId = normalizeId(resolveValue(params.sessionId)) const sessionId = normalizeId(resolveValue(params.sessionId))
const scope = resolveValue(params.scope) const scope = resolveValue(params.scope)
const key = resolveValue(params.key) const cacheId = resolveValue(params.cacheId)
return { instanceId, sessionId, scope, key } const version = String(resolveValue(params.version))
return { instanceId, sessionId, scope, cacheId, version }
}) })
const scopeParams = createMemo(() => { const scopeParams = createMemo(() => {
@@ -73,7 +74,8 @@ interface UseGlobalCacheParams {
instanceId?: MaybeAccessor<string | undefined> instanceId?: MaybeAccessor<string | undefined>
sessionId?: MaybeAccessor<string | undefined> sessionId?: MaybeAccessor<string | undefined>
scope: MaybeAccessor<string> scope: MaybeAccessor<string>
key: MaybeAccessor<string> cacheId: MaybeAccessor<string>
version: MaybeAccessor<string | number>
} }
interface GlobalCacheHandle { interface GlobalCacheHandle {

View File

@@ -8,7 +8,7 @@ const pendingMetadataRequests = new Set<string>()
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean { function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
if (!metadata) return false if (!metadata) return false
return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata && "plugins" in metadata
} }
export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> { export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> {
@@ -30,15 +30,22 @@ export async function loadInstanceMetadata(instance: Instance, options?: { force
pendingMetadataRequests.add(instance.id) pendingMetadataRequests.add(instance.id)
try { try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([ const [projectResult, mcpResult, lspResult, configResult] = await Promise.allSettled([
client.project.current(), client.project.current(),
client.mcp.status(), client.mcp.status(),
fetchLspStatus(instance.id), fetchLspStatus(instance.id),
client.config.get(),
]) ])
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const config = configResult.status === "fulfilled" ? (configResult.value.data as { plugin?: unknown } | undefined) : undefined
const plugins = Array.isArray(config?.plugin)
? (config?.plugin as string[]).map((plugin) =>
plugin.startsWith("file://") ? plugin.slice("file://".length) : plugin,
)
: undefined
const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) } const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) }
@@ -54,10 +61,15 @@ export async function loadInstanceMetadata(instance: Instance, options?: { force
updates.lspStatus = lspStatus ?? [] updates.lspStatus = lspStatus ?? []
} }
if (configResult.status === "fulfilled") {
updates.plugins = plugins ?? []
}
if (!updates?.version && instance.binaryVersion) { if (!updates?.version && instance.binaryVersion) {
updates.version = instance.binaryVersion updates.version = instance.binaryVersion
} }
mergeInstanceMetadata(instance.id, updates) mergeInstanceMetadata(instance.id, updates)
} catch (error) { } catch (error) {
log.error("Failed to load instance metadata", error) log.error("Failed to load instance metadata", error)

View File

@@ -0,0 +1,37 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export class OpencodeApiError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message)
this.name = "OpencodeApiError"
if (options && "cause" in options) {
;(this as any).cause = options.cause
}
}
}
type RequestResultLike<T> =
| {
data: T
error?: undefined
}
| {
data?: undefined
error: unknown
}
export async function requestData<T>(
promise: Promise<RequestResultLike<T> | undefined>,
label: string,
): Promise<T> {
const result = await promise
if (!result) {
throw new OpencodeApiError(`${label} returned no result`)
}
if ((result as any).error) {
throw new OpencodeApiError(`${label} failed`, { cause: (result as any).error })
}
return (result as any).data as T
}
export type { OpencodeClient }

View File

@@ -1,18 +1,20 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client" import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { CODENOMAD_API_BASE } from "./api-client" import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager { class SDKManager {
private clients = new Map<string, OpencodeClient>() private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient { createClient(instanceId: string, proxyPath: string): OpencodeClient {
if (this.clients.has(instanceId)) { const existing = this.clients.get(instanceId)
return this.clients.get(instanceId)! if (existing) {
return existing
} }
const baseUrl = buildInstanceBaseUrl(proxyPath) const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl }) const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client) this.clients.set(instanceId, client)
return client return client
} }
@@ -29,6 +31,8 @@ class SDKManager {
} }
} }
export type { OpencodeClient }
function buildInstanceBaseUrl(proxyPath: string): string { function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath) const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE) const base = stripTrailingSlashes(CODENOMAD_API_BASE)

View File

@@ -7,15 +7,16 @@ import {
} from "../types/message" } from "../types/message"
import type { import type {
EventLspUpdated, EventLspUpdated,
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted, EventSessionCompacted,
EventSessionError, EventSessionError,
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { serverEvents } from "./server-events" import { serverEvents } from "./server-events"
import type { import type {
BackgroundProcess,
InstanceStreamEvent, InstanceStreamEvent,
InstanceStreamStatus, InstanceStreamStatus,
WorkspaceEventPayload, WorkspaceEventPayload,
@@ -37,6 +38,20 @@ interface TuiToastEvent {
} }
} }
interface BackgroundProcessUpdatedEvent {
type: "background.process.updated"
properties: {
process: BackgroundProcess
}
}
interface BackgroundProcessRemovedEvent {
type: "background.process.removed"
properties: {
processId: string
}
}
type SSEEvent = type SSEEvent =
| MessageUpdateEvent | MessageUpdateEvent
| MessageRemovedEvent | MessageRemovedEvent
@@ -46,10 +61,12 @@ type SSEEvent =
| EventSessionCompacted | EventSessionCompacted
| EventSessionError | EventSessionError
| EventSessionIdle | EventSessionIdle
| EventPermissionUpdated | { type: "permission.updated" | "permission.asked"; properties?: any }
| EventPermissionReplied | { type: "permission.replied"; properties?: any }
| EventLspUpdated | EventLspUpdated
| TuiToastEvent | TuiToastEvent
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| { type: string; properties?: Record<string, unknown> } | { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus type ConnectionStatus = InstanceStreamStatus
@@ -117,15 +134,25 @@ class SSEManager {
case "session.idle": case "session.idle":
this.onSessionIdle?.(instanceId, event as EventSessionIdle) this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break break
case "session.status":
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
break
case "permission.updated": case "permission.updated":
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated) case "permission.asked":
this.onPermissionUpdated?.(instanceId, event as any)
break break
case "permission.replied": case "permission.replied":
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied) this.onPermissionReplied?.(instanceId, event as any)
break break
case "lsp.updated": case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated) this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break break
case "background.process.updated":
this.onBackgroundProcessUpdated?.(instanceId, event as BackgroundProcessUpdatedEvent)
break
case "background.process.removed":
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
break
default: default:
log.warn("Unknown SSE event type", { type: event.type }) log.warn("Unknown SSE event type", { type: event.type })
} }
@@ -148,9 +175,12 @@ class SSEManager {
onSessionError?: (instanceId: string, event: EventSessionError) => void onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void> onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): ConnectionStatus | null { getStatus(instanceId: string): ConnectionStatus | null {

View File

@@ -0,0 +1,66 @@
import { createSignal } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { sseManager } from "../lib/sse-manager"
const [backgroundProcesses, setBackgroundProcesses] = createSignal<Map<string, BackgroundProcess[]>>(new Map())
function setProcesses(instanceId: string, processes: BackgroundProcess[]) {
setBackgroundProcesses((prev) => {
const next = new Map(prev)
next.set(instanceId, processes)
return next
})
}
function updateProcess(instanceId: string, process: BackgroundProcess) {
setBackgroundProcesses((prev) => {
const next = new Map(prev)
const current = next.get(instanceId) ?? []
const index = current.findIndex((entry) => entry.id === process.id)
const updated = index >= 0 ? [...current.slice(0, index), process, ...current.slice(index + 1)] : [...current, process]
next.set(instanceId, updated)
return next
})
}
function removeProcess(instanceId: string, processId: string) {
setBackgroundProcesses((prev) => {
const next = new Map(prev)
const current = next.get(instanceId) ?? []
next.set(
instanceId,
current.filter((entry) => entry.id !== processId),
)
return next
})
}
async function loadBackgroundProcesses(instanceId: string) {
const response = await serverApi.listBackgroundProcesses(instanceId)
setProcesses(instanceId, response.processes)
}
function getBackgroundProcesses(instanceId: string): BackgroundProcess[] {
return backgroundProcesses().get(instanceId) ?? []
}
sseManager.onBackgroundProcessUpdated = (instanceId, event) => {
const process = event.properties?.process
if (!process) return
updateProcess(instanceId, process)
}
sseManager.onBackgroundProcessRemoved = (instanceId, event) => {
const processId = event.properties?.processId
if (!processId) return
removeProcess(instanceId, processId)
}
export {
backgroundProcesses,
getBackgroundProcesses,
loadBackgroundProcesses,
removeProcess as removeBackgroundProcess,
updateProcess as updateBackgroundProcess,
}

View File

@@ -1,12 +1,12 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { requestData } from "../lib/opencode-api"
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map()) const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> { export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
const response = await client.command.list() const commands = await requestData<SDKCommand[]>(client.command.list(), "command.list").catch(() => [])
const commands = response.data ?? []
setCommandMap((prev) => { setCommandMap((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instanceId, commands) next.set(instanceId, commands)

View File

@@ -1,6 +1,9 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import type { Instance, LogEntry } from "../types/instance" import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus, Permission } from "@opencode-ai/sdk" import type { LspStatus } from "@opencode-ai/sdk/v2"
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager" import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager" import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
@@ -18,6 +21,7 @@ import { preferences } from "./preferences"
import { setSessionPendingPermission } from "./session-state" import { setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui" import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
import { clearCacheForInstance } from "../lib/global-cache" import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
@@ -31,9 +35,14 @@ const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(ne
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
// Permission queue management per instance // Permission queue management per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map()) const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>() const permissionSessionCounts = new Map<string, Map<string, number>>()
function syncHasInstancesFlag() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
setHasInstances(readyExists)
}
interface DisconnectedInstanceInfo { interface DisconnectedInstanceInfo {
id: string id: string
folder: string folder: string
@@ -68,7 +77,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
updateInstance(descriptor.id, mapped) updateInstance(descriptor.id, mapped)
} else { } else {
addInstance(mapped) addInstance(mapped)
setHasInstances(true)
} }
if (descriptor.status === "ready") { if (descriptor.status === "ready") {
@@ -117,6 +125,37 @@ function releaseInstanceResources(instanceId: string) {
sseManager.seedStatus(instanceId, "disconnected") sseManager.seedStatus(instanceId, "disconnected")
} }
async function syncPendingPermissions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) return
try {
const remote = await requestData<PermissionRequestLike[]>(
instance.client.permission.list(),
"permission.list",
)
const remoteIds = new Set(remote.map((item) => item.id))
const local = getPermissionQueue(instanceId)
// Remove any stale local permissions missing from server.
for (const entry of local) {
if (!remoteIds.has(entry.id)) {
removePermissionFromQueue(instanceId, entry.id)
removePermissionV2(instanceId, entry.id)
}
}
// Upsert all server-side pending permissions.
for (const permission of remote) {
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
}
} catch (error) {
log.warn("Failed to sync pending permissions", { instanceId, error })
}
}
async function hydrateInstanceData(instanceId: string) { async function hydrateInstanceData(instanceId: string) {
try { try {
await fetchSessions(instanceId) await fetchSessions(instanceId)
@@ -126,6 +165,7 @@ async function hydrateInstanceData(instanceId: string) {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance?.client) return if (!instance?.client) return
await fetchCommands(instanceId, instance.client) await fetchCommands(instanceId, instance.client)
await syncPendingPermissions(instanceId)
} catch (error) { } catch (error) {
log.error("Failed to fetch initial data", error) log.error("Failed to fetch initial data", error)
} }
@@ -135,9 +175,6 @@ void (async function initializeWorkspaces() {
try { try {
const workspaces = await serverApi.fetchWorkspaces() const workspaces = await serverApi.fetchWorkspaces()
workspaces.forEach((workspace) => upsertWorkspace(workspace)) workspaces.forEach((workspace) => upsertWorkspace(workspace))
if (workspaces.length === 0) {
setHasInstances(false)
}
} catch (error) { } catch (error) {
log.error("Failed to load workspaces", error) log.error("Failed to load workspaces", error)
} }
@@ -159,9 +196,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
case "workspace.stopped": case "workspace.stopped":
releaseInstanceResources(event.workspaceId) releaseInstanceResources(event.workspaceId)
removeInstance(event.workspaceId) removeInstance(event.workspaceId)
if (instances().size === 0) {
setHasInstances(false)
}
break break
case "workspace.log": case "workspace.log":
handleWorkspaceLog(event.entry) handleWorkspaceLog(event.entry)
@@ -249,6 +283,7 @@ function addInstance(instance: Instance) {
}) })
ensureLogContainer(instance.id) ensureLogContainer(instance.id)
ensureLogStreamingState(instance.id) ensureLogStreamingState(instance.id)
syncHasInstancesFlag()
} }
function updateInstance(id: string, updates: Partial<Instance>) { function updateInstance(id: string, updates: Partial<Instance>) {
@@ -260,6 +295,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
} }
return next return next
}) })
syncHasInstancesFlag()
} }
function removeInstance(id: string) { function removeInstance(id: string) {
@@ -301,6 +337,7 @@ function removeInstance(id: string) {
clearCacheForInstance(id) clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id) messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id) clearInstanceDraftPrompts(id)
syncHasInstancesFlag()
} }
async function createInstance(folder: string, _binaryPath?: string): Promise<string> { async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
@@ -328,9 +365,6 @@ async function stopInstance(id: string) {
} }
removeInstance(id) removeInstance(id)
if (instances().size === 0) {
setHasInstances(false)
}
} }
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> { async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
@@ -349,8 +383,7 @@ async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefin
return undefined return undefined
} }
log.info("lsp.status", { instanceId }) log.info("lsp.status", { instanceId })
const response = await lsp.status() return await requestData<LspStatus[]>(lsp.status(), "lsp.status")
return response.data ?? []
} }
function getActiveInstance(): Instance | null { function getActiveInstance(): Instance | null {
@@ -384,7 +417,7 @@ function clearLogs(id: string) {
} }
// Permission management functions // Permission management functions
function getPermissionQueue(instanceId: string): Permission[] { function getPermissionQueue(instanceId: string): PermissionRequestLike[] {
const queue = permissionQueues().get(instanceId) const queue = permissionQueues().get(instanceId)
if (!queue) { if (!queue) {
return [] return []
@@ -431,7 +464,7 @@ function clearSessionPendingCounts(instanceId: string): void {
permissionSessionCounts.delete(instanceId) permissionSessionCounts.delete(instanceId)
} }
function addPermissionToQueue(instanceId: string, permission: Permission): void { function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
let inserted = false let inserted = false
setPermissionQueues((prev) => { setPermissionQueues((prev) => {
@@ -442,7 +475,7 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
return next return next
} }
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created) const updatedQueue = [...queue, permission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b))
next.set(instanceId, updatedQueue) next.set(instanceId, updatedQueue)
inserted = true inserted = true
return next return next
@@ -461,17 +494,19 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
}) })
const sessionId = getPermissionSessionId(permission) const sessionId = getPermissionSessionId(permission)
incrementSessionPendingCount(instanceId, sessionId) if (sessionId) {
setSessionPendingPermission(instanceId, sessionId, true) incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
}
} }
function removePermissionFromQueue(instanceId: string, permissionId: string): void { function removePermissionFromQueue(instanceId: string, permissionId: string): void {
let removedPermission: Permission | null = null let removedPermission: PermissionRequestLike | null = null
setPermissionQueues((prev) => { setPermissionQueues((prev) => {
const next = new Map(prev) const next = new Map(prev)
const queue = next.get(instanceId) ?? [] const queue = next.get(instanceId) ?? []
const filtered: Permission[] = [] const filtered: PermissionRequestLike[] = []
for (const item of queue) { for (const item of queue) {
if (item.id === permissionId) { if (item.id === permissionId) {
@@ -495,7 +530,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const next = new Map(prev) const next = new Map(prev)
const activeId = next.get(instanceId) const activeId = next.get(instanceId)
if (activeId === permissionId) { if (activeId === permissionId) {
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
next.set(instanceId, nextPermission?.id ?? null) next.set(instanceId, nextPermission?.id ?? null)
} }
return next return next
@@ -504,8 +539,10 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const removed = removedPermission const removed = removedPermission
if (removed) { if (removed) {
const removedSessionId = getPermissionSessionId(removed) const removedSessionId = getPermissionSessionId(removed)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId) if (removedSessionId) {
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
} }
} }
@@ -523,15 +560,13 @@ function clearPermissionQueue(instanceId: string): void {
clearSessionPendingCounts(instanceId) clearSessionPendingCounts(instanceId)
} }
function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
async function sendPermissionResponse( async function sendPermissionResponse(
instanceId: string, instanceId: string,
sessionId: string, sessionId: string,
permissionId: string, requestId: string,
response: "once" | "always" | "reject" reply: PermissionReply
): Promise<void> { ): Promise<void> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance?.client) { if (!instance?.client) {
@@ -539,13 +574,16 @@ async function sendPermissionResponse(
} }
try { try {
await instance.client.postSessionIdPermissionsPermissionId({ await requestData(
path: { id: sessionId, permissionID: permissionId }, instance.client.permission.reply({
body: { response }, requestID: requestId,
}) reply,
}),
"permission.reply",
)
// Remove from queue after successful response // Remove from queue after successful response
removePermissionFromQueue(instanceId, permissionId) removePermissionFromQueue(instanceId, requestId)
} catch (error) { } catch (error) {
log.error("Failed to send permission response", error) log.error("Failed to send permission response", error)
throw error throw error
@@ -590,9 +628,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
log.error("Failed to stop disconnected instance", error) log.error("Failed to stop disconnected instance", error)
} finally { } finally {
setDisconnectedInstance(null) setDisconnectedInstance(null)
if (instances().size === 0) {
setHasInstances(false)
}
} }
} }

View File

@@ -1,4 +1,5 @@
import type { Permission } from "@opencode-ai/sdk" import type { PermissionRequestLike } from "../../types/permission"
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus" import { messageStoreBus } from "./bus"
@@ -107,42 +108,108 @@ export function replaceMessageIdV2(instanceId: string, oldId: string, newId: str
store.replaceMessageId({ oldId, newId }) store.replaceMessageId({ oldId, newId })
} }
function extractPermissionMessageId(permission: Permission): string | undefined { function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {
return (permission as any).messageID || (permission as any).messageId return getPermissionMessageId(permission)
} }
function extractPermissionPartId(permission: Permission): string | undefined { function extractPermissionPartId(permission: PermissionRequestLike): string | undefined {
const metadata = (permission as any).metadata || {} const metadata = (permission as any).metadata || {}
return ( return (
(permission as any).callID || (permission as any).partID ||
(permission as any).callId || (permission as any).partId ||
(permission as any).toolCallID ||
(permission as any).toolCallId ||
metadata.partId ||
metadata.partID || metadata.partID ||
metadata.callID || metadata.partId ||
metadata.callId ||
undefined undefined
) )
} }
export function upsertPermissionV2(instanceId: string, permission: Permission): void { function extractPermissionCallId(permission: PermissionRequestLike): string | undefined {
return getPermissionCallId(permission)
}
function resolvePartIdFromCallId(store: ReturnType<typeof messageStoreBus.getOrCreate>, messageId?: string, callId?: string): string | undefined {
if (!messageId || !callId) return undefined
const record = store.getMessage(messageId)
if (!record) return undefined
for (const partId of record.partIds) {
const part = record.parts[partId]?.data
if (!part || part.type !== "tool") continue
const toolCallId =
(part as any).callID ??
(part as any).callId ??
(part as any).toolCallID ??
(part as any).toolCallId ??
undefined
if (toolCallId === callId && typeof part.id === "string" && part.id.length > 0) {
return part.id
}
}
return undefined
}
export function upsertPermissionV2(instanceId: string, permission: PermissionRequestLike): void {
if (!permission) return if (!permission) return
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const messageId = extractPermissionMessageId(permission)
let partId = extractPermissionPartId(permission)
if (!partId) {
const callId = extractPermissionCallId(permission)
partId = resolvePartIdFromCallId(store, messageId, callId)
}
store.upsertPermission({ store.upsertPermission({
permission, permission,
messageId: extractPermissionMessageId(permission), messageId,
partId: extractPermissionPartId(permission), partId,
enqueuedAt: (permission as any).time?.created ?? Date.now(), enqueuedAt: (permission as any).time?.created ?? Date.now(),
}) })
} }
export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: string): void {
const store = messageStoreBus.getOrCreate(instanceId)
const pending = store.state.permissions.queue
if (!pending || pending.length === 0) return
for (const entry of pending) {
if (!entry || entry.partId) continue
const permission = entry.permission
if (!permission) continue
const permissionSessionId = (permission as any)?.sessionID ?? (permission as any)?.sessionId ?? undefined
if (sessionId && permissionSessionId && permissionSessionId !== sessionId) {
continue
}
const messageId = entry.messageId ?? extractPermissionMessageId(permission)
const callId = extractPermissionCallId(permission)
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
if (!resolvedPartId) continue
store.upsertPermission({
...entry,
messageId,
partId: resolvedPartId,
})
}
}
export function removePermissionV2(instanceId: string, permissionId: string): void { export function removePermissionV2(instanceId: string, permissionId: string): void {
if (!permissionId) return if (!permissionId) return
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
store.removePermission(permissionId) store.removePermission(permissionId)
} }
export function removeMessageV2(instanceId: string, messageId: string): void {
if (!messageId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removeMessage(messageId)
}
export function removeMessagePartV2(instanceId: string, messageId: string, partId: string): void {
if (!messageId || !partId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removeMessagePart(messageId, partId)
}
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void { export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
if (!session) return if (!session) return
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -51,16 +51,8 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
return part.id return part.id
} }
const toolCallId = if (part.type === "tool") {
(part as any).callID ?? throw new Error("Tool part missing id")
(part as any).callId ??
(part as any).toolCallID ??
(part as any).toolCallId ??
undefined
if (part.type === "tool" && typeof toolCallId === "string" && toolCallId.length > 0) {
part.id = toolCallId
return toolCallId
} }
const fallbackId = `${messageId}-part-${index}` const fallbackId = `${messageId}-part-${index}`
@@ -191,6 +183,8 @@ export interface InstanceMessageStore {
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
upsertMessage: (input: MessageUpsertInput) => void upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void applyPartUpdate: (input: PartUpdateInput) => void
removeMessage: (messageId: string) => void
removeMessagePart: (messageId: string, partId: string) => void
bufferPendingPart: (entry: PendingPartEntry) => void bufferPendingPart: (entry: PendingPartEntry) => void
flushPendingParts: (messageId: string) => void flushPendingParts: (messageId: string) => void
replaceMessageId: (options: ReplaceMessageIdOptions) => void replaceMessageId: (options: ReplaceMessageIdOptions) => void
@@ -502,6 +496,52 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}) })
} }
function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) {
if (!messageId || !partId || part.type !== "tool") {
return
}
const toolCallId =
(part as any).callID ??
(part as any).callId ??
(part as any).toolCallID ??
(part as any).toolCallId ??
undefined
if (!toolCallId) {
return
}
setState(
"permissions",
"byMessage",
messageId,
produce((draft) => {
if (!draft) return
const existing = draft[partId]
for (const [key, entry] of Object.entries(draft)) {
if (!entry || entry.partId) continue
const permissionCallId =
(entry.permission as any).tool?.callID ??
(entry.permission as any).tool?.callId ??
(entry.permission as any).callID ??
(entry.permission as any).callId ??
(entry.permission as any).toolCallID ??
(entry.permission as any).toolCallId ??
(entry.permission as any).metadata?.callID ??
(entry.permission as any).metadata?.callId ??
undefined
if (permissionCallId !== toolCallId) continue
if (!existing || existing.permission.id === entry.permission.id) {
entry.partId = partId
draft[partId] = entry
delete draft[key]
}
break
}
}),
)
}
function applyPartUpdate(input: PartUpdateInput) { function applyPartUpdate(input: PartUpdateInput) {
const message = state.messages[input.messageId] const message = state.messages[input.messageId]
if (!message) { if (!message) {
@@ -520,7 +560,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
draft.partIds = [...draft.partIds, partId] draft.partIds = [...draft.partIds, partId]
} }
const existing = draft.parts[partId] const existing = draft.parts[partId]
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0 const nextRevision = existing ? existing.revision + 1 : (cloned as any).version ?? 0
draft.parts[partId] = { draft.parts[partId] = {
id: partId, id: partId,
data: cloned, data: cloned,
@@ -533,6 +573,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}), }),
) )
rebindPermissionForPart(input.messageId, partId, cloned)
if (isCompletedTodoPart(cloned)) { if (isCompletedTodoPart(cloned)) {
recordLatestTodoSnapshot(message.sessionId, { recordLatestTodoSnapshot(message.sessionId, {
messageId: input.messageId, messageId: input.messageId,
@@ -546,6 +588,100 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bumpSessionRevision(message.sessionId) bumpSessionRevision(message.sessionId)
} }
function removeMessage(messageId: string) {
if (!messageId) return
const record = state.messages[messageId]
const sessionIds = new Set<string>()
if (record?.sessionId) {
sessionIds.add(record.sessionId)
} else {
Object.values(state.sessions).forEach((session) => {
if (session.messageIds.includes(messageId)) {
sessionIds.add(session.id)
}
})
}
clearRecordDisplayCacheForMessages(instanceId, [messageId])
batch(() => {
sessionIds.forEach((sessionId) => {
setState("sessions", sessionId, "messageIds", (ids = []) => ids.filter((id) => id !== messageId))
})
setState("messages", (prev) => {
if (!prev[messageId]) return prev
const next = { ...prev }
delete next[messageId]
return next
})
setState("messageInfoVersion", (prev) => {
if (!(messageId in prev)) return prev
const next = { ...prev }
delete next[messageId]
return next
})
messageInfoCache.delete(messageId)
setState("pendingParts", (prev) => {
if (!prev[messageId]) return prev
const next = { ...prev }
delete next[messageId]
return next
})
setState("permissions", "byMessage", (prev) => {
if (!prev[messageId]) return prev
const next = { ...prev }
delete next[messageId]
return next
})
sessionIds.forEach((sessionId) => {
withUsageState(sessionId, (draft) => removeUsageEntry(draft, messageId))
if (state.latestTodos[sessionId]?.messageId === messageId) {
clearLatestTodoSnapshot(sessionId)
}
bumpSessionRevision(sessionId)
})
})
}
function removeMessagePart(messageId: string, partId: string) {
if (!messageId || !partId) return
const message = state.messages[messageId]
if (!message) return
clearRecordDisplayCacheForMessages(instanceId, [messageId])
batch(() => {
setState(
"messages",
messageId,
produce((draft: MessageRecord) => {
if (!draft.parts[partId] && !draft.partIds.includes(partId)) return
draft.partIds = draft.partIds.filter((id) => id !== partId)
delete draft.parts[partId]
draft.updatedAt = Date.now()
draft.revision += 1
}),
)
setState("permissions", "byMessage", messageId, (prev) => {
if (!prev || !prev[partId]) return prev
const next = { ...prev }
delete next[partId]
return next
})
bumpSessionRevision(message.sessionId)
})
}
function flushPendingParts(messageId: string) { function flushPendingParts(messageId: string) {
const pending = state.pendingParts[messageId] const pending = state.pendingParts[messageId]
@@ -644,7 +780,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function upsertPermission(entry: PermissionEntry) { function upsertPermission(entry: PermissionEntry) {
const messageKey = entry.messageId ?? "__global__" const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? "__global__" const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
setState( setState(
"permissions", "permissions",
@@ -868,8 +1004,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
addOrUpdateSession, addOrUpdateSession,
hydrateMessages, hydrateMessages,
upsertMessage, upsertMessage,
applyPartUpdate, applyPartUpdate,
bufferPendingPart, removeMessage,
removeMessagePart,
bufferPendingPart,
flushPendingParts, flushPendingParts,
replaceMessageId, replaceMessageId,
setMessageInfo, setMessageInfo,

View File

@@ -26,35 +26,13 @@ function decodeTextSegment(segment: any): any {
return segment return segment
} }
function deriveToolPartId(part: any): string | undefined {
if (!part || typeof part !== "object") {
return undefined
}
if (part.type !== "tool") {
return undefined
}
const callId =
part.callID ??
part.callId ??
part.toolCallID ??
part.toolCallId ??
undefined
if (typeof callId === "string" && callId.length > 0) {
return callId
}
return undefined
}
export function normalizeMessagePart(part: any): any { export function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") { if (!part || typeof part !== "object") {
return part return part
} }
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") { if (part.type === "tool" && (typeof part.id !== "string" || part.id.length === 0)) {
const inferredId = deriveToolPartId(part) throw new Error("Tool part missing id")
if (inferredId) {
part = { ...part, id: inferredId }
}
} }
if (part.type !== "text") { if (part.type !== "text") {

View File

@@ -1,8 +1,10 @@
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
import type { MessageRecord } from "./types" import type { MessageRecord } from "./types"
type ClientPartWithRevision = ClientPart & { revision?: number }
export interface RecordDisplayData { export interface RecordDisplayData {
orderedParts: ClientPart[] orderedParts: ClientPartWithRevision[]
} }
interface RecordDisplayCacheEntry { interface RecordDisplayCacheEntry {
@@ -23,12 +25,12 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord
return cached.data return cached.data
} }
const orderedParts: ClientPart[] = [] const orderedParts: ClientPartWithRevision[] = []
for (const partId of record.partIds) { for (const partId of record.partIds) {
const entry = record.parts[partId] const entry = record.parts[partId]
if (!entry?.data) continue if (!entry?.data) continue
orderedParts.push(entry.data) orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision })
} }
const data: RecordDisplayData = { orderedParts } const data: RecordDisplayData = { orderedParts }

View File

@@ -1,5 +1,5 @@
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
import type { Permission } from "@opencode-ai/sdk" import type { PermissionRequestLike } from "../../types/permission"
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
export type MessageRole = "user" | "assistant" export type MessageRole = "user" | "assistant"
@@ -47,7 +47,7 @@ export interface PendingPartEntry {
} }
export interface PermissionEntry { export interface PermissionEntry {
permission: Permission permission: PermissionRequestLike
messageId?: string messageId?: string
partId?: string partId?: string
enqueuedAt: number enqueuedAt: number

View File

@@ -7,6 +7,7 @@ import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("actions") const log = getLogger("actions")
@@ -179,17 +180,13 @@ async function sendMessage(
try { try {
log.info("session.promptAsync", { instanceId, sessionId, requestBody }) log.info("session.promptAsync", { instanceId, sessionId, requestBody })
const response = await instance.client.session.promptAsync({ await requestData(
path: { id: sessionId }, instance.client.session.promptAsync({
body: requestBody, sessionID: sessionId,
}) ...(requestBody as any),
}),
log.info("sendMessage response", response) "session.promptAsync",
)
if (response.error) {
log.error("sendMessage server error", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
} catch (error) { } catch (error) {
log.error("Failed to send prompt", error) log.error("Failed to send prompt", error)
throw error throw error
@@ -232,10 +229,13 @@ async function executeCustomCommand(
body.model = `${session.model.providerId}/${session.model.modelId}` body.model = `${session.model.providerId}/${session.model.modelId}`
} }
await instance.client.session.command({ await requestData(
path: { id: sessionId }, instance.client.session.command({
body, sessionID: sessionId,
}) ...(body as any),
}),
"session.command",
)
} }
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> { async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
@@ -251,13 +251,14 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
const agent = session.agent || "build" const agent = session.agent || "build"
await instance.client.session.shell({ await requestData(
path: { id: sessionId }, instance.client.session.shell({
body: { sessionID: sessionId,
agent, agent,
command, command,
}, }),
}) "session.shell",
)
} }
async function abortSession(instanceId: string, sessionId: string): Promise<void> { async function abortSession(instanceId: string, sessionId: string): Promise<void> {
@@ -270,9 +271,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
try { try {
log.info("session.abort", { instanceId, sessionId }) log.info("session.abort", { instanceId, sessionId })
await instance.client.session.abort({ await requestData(
path: { id: sessionId }, instance.client.session.abort({
}) sessionID: sessionId,
}),
"session.abort",
)
log.info("abortSession complete", { instanceId, sessionId }) log.info("abortSession complete", { instanceId, sessionId })
} catch (error) { } catch (error) {
log.error("Failed to abort session", error) log.error("Failed to abort session", error)
@@ -350,10 +354,13 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
throw new Error("Session title is required") throw new Error("Session title is required")
} }
await instance.client.session.update({ await requestData(
path: { id: sessionId }, instance.client.session.update({
body: { title: trimmedTitle }, sessionID: sessionId,
}) title: trimmedTitle,
}),
"session.update",
)
withSession(instanceId, sessionId, (current) => { withSession(instanceId, sessionId, (current) => {
current.title = trimmedTitle current.title = trimmedTitle

View File

@@ -1,9 +1,8 @@
import type { Session } from "../types/session" import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message" import type { Message } from "../types/message"
import { instances } from "./instances" import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences" import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import { import {
activeSessionId, activeSessionId,
agents, agents,
@@ -23,14 +22,16 @@ import {
loading, loading,
setLoading, setLoading,
cleanupBlankSessions, cleanupBlankSessions,
syncInstanceSessionIndicator,
} from "./session-state" } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2 } from "./message-v2/bridge" import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache" import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("api") const log = getLogger("api")
@@ -77,10 +78,30 @@ async function fetchSessions(instanceId: string): Promise<void> {
return return
} }
let statusById: Record<string, any> = {}
try {
const statusResponse = await instance.client.session.status()
if (statusResponse.data && typeof statusResponse.data === "object") {
statusById = statusResponse.data as Record<string, any>
}
} catch (error) {
log.error("Failed to fetch session status:", error)
}
const existingSessions = sessions().get(instanceId) const existingSessions = sessions().get(instanceId)
for (const apiSession of response.data) { for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id) const existingSession = existingSessions?.get(apiSession.id)
const existingStatus = existingSession?.status
let status: SessionStatus
if (existingStatus === "compacting") {
status = "compacting"
} else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
}
sessionMap.set(apiSession.id, { sessionMap.set(apiSession.id, {
id: apiSession.id, id: apiSession.id,
@@ -89,6 +110,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
parentId: apiSession.parentID || null, parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "", agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" }, model: existingSession?.model ?? { providerId: "", modelId: "" },
status,
version: apiSession.version, version: apiSession.version,
time: { time: {
...apiSession.time, ...apiSession.time,
@@ -112,6 +134,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
return next return next
}) })
syncInstanceSessionIndicator(instanceId, sessionMap)
setMessagesLoaded((prev) => { setMessagesLoaded((prev) => {
const next = new Map(prev) const next = new Map(prev)
const loadedSet = next.get(instanceId) const loadedSet = next.get(instanceId)
@@ -127,11 +151,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
return next return next
}) })
for (const session of sessionMap.values()) {
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
setSessionCompactionState(instanceId, session.id, active)
}
pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
} catch (error) { } catch (error) {
@@ -183,6 +202,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
parentId: null, parentId: null,
agent: selectedAgent, agent: selectedAgent,
model: defaultModel, model: defaultModel,
status: "idle",
version: response.data.version, version: response.data.version,
time: { time: {
...response.data.time, ...response.data.time,
@@ -205,6 +225,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
return next return next
}) })
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || [] const instanceProviders = providers().get(instanceId) || []
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId) const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
@@ -261,25 +283,16 @@ async function forkSession(
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const request: { const request: { sessionID: string; messageID?: string } = {
path: { id: string } sessionID: sourceSessionId,
body?: { messageID: string } messageID: options?.messageId,
} = {
path: { id: sourceSessionId },
}
if (options?.messageId) {
request.body = { messageID: options.messageId }
} }
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const response = await instance.client.session.fork(request) const info = await requestData<SessionForkResponse>(
instance.client.session.fork(request),
if (!response.data) { "session.fork",
throw new Error("Failed to fork session: No data returned") )
}
const info = response.data as SessionForkResponse
const forkedSession = { const forkedSession = {
id: info.id, id: info.id,
instanceId, instanceId,
@@ -290,6 +303,7 @@ async function forkSession(
providerId: info.model?.providerID || "", providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "", modelId: info.model?.modelID || "",
}, },
status: "idle",
version: "0", version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() }, time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert revert: info.revert
@@ -310,6 +324,8 @@ async function forkSession(
return next return next
}) })
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || [] const instanceProviders = providers().get(instanceId) || []
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
@@ -356,18 +372,22 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
try { try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId }) log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await instance.client.session.delete({ path: { id: sessionId } }) await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
const instanceSessions = next.get(instanceId) const instanceSessions = next.get(instanceId)
if (instanceSessions) { if (instanceSessions) {
instanceSessions.delete(sessionId) instanceSessions.delete(sessionId)
if (instanceSessions.size === 0) {
next.delete(instanceId)
}
} }
return next return next
}) })
setSessionCompactionState(instanceId, sessionId, false) syncInstanceSessionIndicator(instanceId)
clearSessionDraftPrompt(instanceId, sessionId) clearSessionDraftPrompt(instanceId, sessionId)
// Drop normalized message state and caches for this session // Drop normalized message state and caches for this session
@@ -519,14 +539,30 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
try { try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const response = await instance.client.session["messages"]({ path: { id: sessionId } }) const apiMessages = await requestData<any[]>(
instance.client.session.messages({ sessionID: sessionId }),
"session.messages",
)
if (!response.data || !Array.isArray(response.data)) { if (!Array.isArray(apiMessages)) {
return
}
// Treat empty sessions as loaded to avoid re-fetch loops.
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
if (apiMessages.length === 0) {
return return
} }
const messagesInfo = new Map<string, any>() const messagesInfo = new Map<string, any>()
const messages: Message[] = response.data.map((apiMessage: any) => { const messages: Message[] = apiMessages.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage const info = apiMessage.info || apiMessage
const role = info.role || "assistant" const role = info.role || "assistant"
const messageId = info.id || String(Date.now()) const messageId = info.id || String(Date.now())
@@ -552,8 +588,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
let providerID = "" let providerID = ""
let modelID = "" let modelID = ""
for (let i = response.data.length - 1; i >= 0; i--) { for (let i = apiMessages.length - 1; i >= 0; i--) {
const apiMessage = response.data[i] const apiMessage = apiMessages[i]
const info = apiMessage.info || apiMessage const info = apiMessage.info || apiMessage
if (info.role === "assistant") { if (info.role === "assistant") {
@@ -574,19 +610,23 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
const nextInstanceSessions = next.get(instanceId) const nextInstanceSessions = next.get(instanceId)
if (nextInstanceSessions) { if (!nextInstanceSessions) {
const existingSession = nextInstanceSessions.get(sessionId) return next
if (existingSession) {
const updatedSession = {
...existingSession,
agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
}
const updatedInstanceSessions = new Map(nextInstanceSessions)
updatedInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, updatedInstanceSessions)
}
} }
const existingSession = nextInstanceSessions.get(sessionId)
if (!existingSession) {
return next
}
const updatedSession = {
...existingSession,
agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
}
nextInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, nextInstanceSessions)
return next return next
}) })
@@ -606,6 +646,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
} }
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo) seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
// Permissions can be hydrated before messages/tool parts exist in the store.
// After message hydration, try to attach any pending permissions to tool-call part ids.
reconcilePendingPermissionsV2(instanceId, sessionId)
} catch (error) { } catch (error) {
log.error("Failed to load messages:", error) log.error("Failed to load messages:", error)
throw error throw error

View File

@@ -6,37 +6,43 @@ import type {
MessageUpdateEvent, MessageUpdateEvent,
} from "../types/message" } from "../types/message"
import type { import type {
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted, EventSessionCompacted,
EventSessionError, EventSessionError,
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { MessageStatus } from "./message-v2/types" import type { MessageStatus } from "./message-v2/types"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
const log = getLogger("sse")
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
import { import {
applyPartUpdateV2, applyPartUpdateV2,
replaceMessageIdV2, replaceMessageIdV2,
upsertMessageInfoV2, upsertMessageInfoV2,
upsertPermissionV2, upsertPermissionV2,
removeMessagePartV2,
removeMessageV2,
removePermissionV2, removePermissionV2,
setSessionRevertV2, setSessionRevertV2,
} from "./message-v2/bridge" } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store" import type { InstanceMessageStore } from "./message-v2/instance-store"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
interface TuiToastEvent { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
properties: { properties: {
@@ -49,8 +55,98 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle"
if (current === status) return false
if (current === "compacting" && status !== "compacting") {
return false
}
session.status = status
})
}
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
const instance = instances().get(instanceId)
if (!instance?.client) return null
try {
const info = await requestData<any>(
instance.client.session.get({ sessionID: sessionId }),
"session.get",
)
let fetchedStatus: SessionStatus = "idle"
try {
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status")
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
} catch (error) {
log.error("Failed to fetch session status", error)
}
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
let updatedInstanceSessions: Map<string, Session> | undefined
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
const existing = instanceSessions.get(sessionId)
const merged: Session = {
...fetched,
agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
}
instanceSessions.set(sessionId, merged)
next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
return next
})
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
return fetched
} catch (error) {
log.error("Failed to fetch session info", error)
return null
}
}
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId)
if (existing) {
if ((existing.status ?? "idle") === status) {
return
}
applySessionStatus(instanceId, sessionId, status)
return
}
const key = `${instanceId}:${sessionId}`
if (pendingSessionFetches.has(key)) {
return
}
const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId)
if (!fetched) return
applySessionStatus(instanceId, sessionId, status)
})()
pendingSessionFetches.set(key, pending)
void pending.finally(() => pendingSessionFetches.delete(key))
}
type MessageRole = "user" | "assistant" type MessageRole = "user" | "assistant"
function resolveMessageRole(info?: MessageInfo | null): MessageRole { function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant" return info?.role === "user" ? "user" : "assistant"
} }
@@ -72,7 +168,6 @@ function findPendingMessageId(
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void { function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const rawPart = event.properties?.part const rawPart = event.properties?.part
@@ -87,9 +182,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return if (!sessionId || !messageId) return
if (part.type === "compaction") {
const session = instanceSessions.get(sessionId) ensureSessionStatus(instanceId, sessionId, "compacting")
if (!session) return }
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = resolveMessageRole(messageInfo) const role: MessageRole = resolveMessageRole(messageInfo)
@@ -133,10 +228,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId) withSession(instanceId, sessionId, (session) => {
if (!session) return session.time = { ...(session.time ?? {}), updated: Date.now() }
})
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = info.role === "user" ? "user" : "assistant" const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error) const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete" const status: MessageStatus = hasError ? "error" : "complete"
@@ -174,12 +271,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
if (!info) return if (!info) return
const compactingFlag = info.time?.compacting const instanceSessions = sessions().get(instanceId) ?? new Map<string, Session>()
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
setSessionCompactionState(instanceId, info.id, isCompacting)
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const existingSession = instanceSessions.get(info.id) const existingSession = instanceSessions.get(info.id)
@@ -194,6 +286,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
providerId: "", providerId: "",
modelId: "", modelId: "",
}, },
status: "idle",
version: info.version || "0", version: info.version || "0",
time: info.time time: info.time
? { ...info.time } ? { ...info.time }
@@ -201,15 +294,20 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
}, },
} as any } as Session
let updatedInstanceSessions: Map<string, Session> | undefined
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
const updated = new Map(prev.get(instanceId)) const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
updated.set(newSession.id, newSession) instanceSessions.set(newSession.id, newSession)
next.set(instanceId, updated) next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
return next return next
}) })
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
setSessionRevertV2(instanceId, info.id, info.revert ?? null) setSessionRevertV2(instanceId, info.id, info.revert ?? null)
log.info(`[SSE] New session created: ${info.id}`, newSession) log.info(`[SSE] New session created: ${info.id}`, newSession)
@@ -218,13 +316,10 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession.time, ...existingSession.time,
...(info.time ?? {}), ...(info.time ?? {}),
} }
if (!info.time?.updated) {
mergedTime.updated = Date.now()
}
const updatedSession = { const updatedSession = {
...existingSession, ...existingSession,
title: info.title || existingSession.title, title: info.title || existingSession.title,
status: existingSession.status ?? "idle",
time: mergedTime, time: mergedTime,
revert: info.revert revert: info.revert
? { ? {
@@ -236,37 +331,53 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
: existingSession.revert, : existingSession.revert,
} }
let updatedInstanceSessions: Map<string, Session> | undefined
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
const updated = new Map(prev.get(instanceId)) const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
updated.set(existingSession.id, updatedSession) instanceSessions.set(existingSession.id, updatedSession)
next.set(instanceId, updated) next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
return next return next
}) })
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
setSessionRevertV2(instanceId, info.id, info.revert ?? null) setSessionRevertV2(instanceId, info.id, info.revert ?? null)
} }
} }
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void { function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
ensureSessionStatus(instanceId, sessionId, "idle")
log.info(`[SSE] Session idle: ${sessionId}`) log.info(`[SSE] Session idle: ${sessionId}`)
} }
function handleSessionStatus(instanceId: string, event: EventSessionStatus): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status)
ensureSessionStatus(instanceId, sessionId, status)
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
}
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void { function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
const sessionID = event.properties?.sessionID const sessionID = event.properties?.sessionID
if (!sessionID) return if (!sessionID) return
log.info(`[SSE] Session compacted: ${sessionID}`) log.info(`[SSE] Session compacted: ${sessionID}`)
setSessionCompactionState(instanceId, sessionID, false) const existing = sessions().get(instanceId)?.get(sessionID)
if (existing) {
withSession(instanceId, sessionID, (session) => { withSession(instanceId, sessionID, (session) => {
const time = { ...(session.time ?? {}) } session.status = "working"
time.compacting = 0 })
session.time = time } else {
}) ensureSessionStatus(instanceId, sessionID, "working")
}
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error)) loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
@@ -305,19 +416,21 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
} }
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void { function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
const sessionID = event.properties?.sessionID const { sessionID, messageID } = event.properties
if (!sessionID) return if (!sessionID || !messageID) return
log.info(`[SSE] Message removed from session ${sessionID}, reloading messages`) log.info(`[SSE] Message removed from session ${sessionID}`, { messageID })
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after removal", error)) removeMessageV2(instanceId, messageID)
updateSessionInfo(instanceId, sessionID)
} }
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void { function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
const sessionID = event.properties?.sessionID const { sessionID, messageID, partID } = event.properties
if (!sessionID) return if (!sessionID || !messageID || !partID) return
log.info(`[SSE] Message part removed from session ${sessionID}, reloading messages`) log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID })
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after part removal", error)) removeMessagePartV2(instanceId, messageID, partID)
updateSessionInfo(instanceId, sessionID)
} }
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
@@ -337,22 +450,23 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
}) })
} }
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void { function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void {
const permission = event.properties const permission = event?.properties as PermissionRequestLike | undefined
if (!permission) return if (!permission) return
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`) log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
addPermissionToQueue(instanceId, permission) addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission) upsertPermissionV2(instanceId, permission)
} }
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void { function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
const { permissionID } = event.properties const properties = event?.properties as PermissionReplyEventPropertiesLike | undefined
if (!permissionID) return const requestId = getRequestIdFromPermissionReply(properties)
if (!requestId) return
log.info(`[SSE] Permission replied: ${permissionID}`) log.info(`[SSE] Permission replied: ${requestId}`)
removePermissionFromQueue(instanceId, permissionID) removePermissionFromQueue(instanceId, requestId)
removePermissionV2(instanceId, permissionID) removePermissionV2(instanceId, requestId)
} }
export { export {
@@ -364,6 +478,7 @@ export {
handleSessionCompacted, handleSessionCompacted,
handleSessionError, handleSessionError,
handleSessionIdle, handleSessionIdle,
handleSessionStatus,
handleSessionUpdate, handleSessionUpdate,
handleTuiToast, handleTuiToast,
} }

View File

@@ -1,12 +1,13 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session" import type { Session, SessionStatus, Agent, Provider } from "../types/session"
import { deleteSession, loadMessages } from "./session-api" import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { instances } from "./instances" import { instances } from "./instances"
import { showConfirmDialog } from "./alerts" import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("session") const log = getLogger("session")
@@ -39,6 +40,130 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map()) const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
type InstanceIndicatorCounts = {
permission: number
working: number
compacting: number
}
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission) {
return "permission"
}
const status = session.status ?? "idle"
return status
}
function adjustIndicatorCounts(
instanceId: string,
previous: InstanceSessionIndicatorStatus | "idle",
next: InstanceSessionIndicatorStatus | "idle",
): void {
if (previous === next) return
const decKey = previous === "idle" ? null : previous
const incKey = next === "idle" ? null : next
setInstanceIndicatorCounts((prev) => {
const current = prev.get(instanceId) ?? { permission: 0, working: 0, compacting: 0 }
const updated: InstanceIndicatorCounts = { ...current }
if (decKey) {
updated[decKey] = Math.max(0, updated[decKey] - 1)
}
if (incKey) {
updated[incKey] = updated[incKey] + 1
}
const hasAny = updated.permission > 0 || updated.working > 0 || updated.compacting > 0
if (!hasAny) {
if (!prev.has(instanceId)) return prev
const nextMap = new Map(prev)
nextMap.delete(instanceId)
return nextMap
}
const same =
current.permission === updated.permission &&
current.working === updated.working &&
current.compacting === updated.compacting
if (same && prev.has(instanceId)) {
return prev
}
const nextMap = new Map(prev)
nextMap.set(instanceId, updated)
return nextMap
})
}
function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<string, Session> | undefined): void {
if (!instanceSessions || instanceSessions.size === 0) {
setInstanceIndicatorCounts((prev) => {
if (!prev.has(instanceId)) return prev
const next = new Map(prev)
next.delete(instanceId)
return next
})
return
}
let permission = 0
let working = 0
let compacting = 0
for (const session of instanceSessions.values()) {
if (session.pendingPermission) {
permission += 1
continue
}
const status = session.status ?? "idle"
if (status === "compacting") {
compacting += 1
} else if (status === "working") {
working += 1
}
}
if (permission === 0 && working === 0 && compacting === 0) {
setInstanceIndicatorCounts((prev) => {
if (!prev.has(instanceId)) return prev
const next = new Map(prev)
next.delete(instanceId)
return next
})
return
}
setInstanceIndicatorCounts((prev) => {
const current = prev.get(instanceId)
if (current && current.permission === permission && current.working === working && current.compacting === compacting) {
return prev
}
const next = new Map(prev)
next.set(instanceId, { permission, working, compacting })
return next
})
}
export function getInstanceSessionIndicatorStatusCached(instanceId: string): InstanceSessionIndicatorStatus {
const counts = instanceIndicatorCounts().get(instanceId)
if (!counts) return "idle"
if (counts.permission > 0) return "permission"
if (counts.compacting > 0) return "compacting"
if (counts.working > 0) return "working"
return "idle"
}
export function syncInstanceSessionIndicator(instanceId: string, instanceSessions?: Map<string, Session>): void {
recomputeIndicatorCounts(instanceId, instanceSessions ?? sessions().get(instanceId))
}
function clearLoadedFlag(instanceId: string, sessionId: string) { function clearLoadedFlag(instanceId: string, sessionId: string) {
if (!instanceId || !sessionId) return if (!instanceId || !sessionId) return
setMessagesLoaded((prev) => { setMessagesLoaded((prev) => {
@@ -130,39 +255,44 @@ function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
}) })
} }
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) { function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void | boolean) {
const instanceSessions = sessions().get(instanceId) let previousBucket: InstanceSessionIndicatorStatus | "idle" | null = null
if (!instanceSessions) return let nextBucket: InstanceSessionIndicatorStatus | "idle" | null = null
let didUpdate = false
const session = instanceSessions.get(sessionId)
if (!session) return
updater(session)
const updatedSession = {
...session,
}
setSessions((prev) => { setSessions((prev) => {
const instanceSessions = prev.get(instanceId)
if (!instanceSessions) return prev
const current = instanceSessions.get(sessionId)
if (!current) return prev
previousBucket = getIndicatorBucket(current)
const updatedSession: Session = { ...current }
const result = updater(updatedSession)
if (result === false) {
return prev
}
nextBucket = getIndicatorBucket(updatedSession)
instanceSessions.set(sessionId, updatedSession)
didUpdate = true
const next = new Map(prev) const next = new Map(prev)
const newInstanceSessions = new Map(instanceSessions) next.set(instanceId, instanceSessions)
newInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, newInstanceSessions)
return next return next
}) })
}
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void { if (didUpdate && previousBucket && nextBucket) {
withSession(instanceId, sessionId, (session) => { adjustIndicatorCounts(instanceId, previousBucket, nextBucket)
const time = { ...(session.time ?? {}) } }
time.compacting = isCompacting ? Date.now() : 0
session.time = time
})
} }
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
if (session.pendingPermission === pending) return if (session.pendingPermission === pending) return false
session.pendingPermission = pending session.pendingPermission = pending
}) })
} }
@@ -199,6 +329,13 @@ function clearActiveParentSession(instanceId: string): void {
}) })
} }
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
withSession(instanceId, sessionId, (session) => {
if (session.status === status) return false
session.status = status
})
}
function getActiveParentSession(instanceId: string): Session | null { function getActiveParentSession(instanceId: string): Session | null {
const parentId = activeParentSessionId().get(instanceId) const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null if (!parentId) return null
@@ -272,8 +409,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
} }
let messages: any[] = [] let messages: any[] = []
try { try {
const response = await instance.client.session.messages({ path: { id: session.id } }) messages = await requestData<any[]>(
messages = response.data || [] instance.client.session.messages({ sessionID: session.id }),
"session.messages",
)
} catch (error) { } catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error) log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession return isFreshSession
@@ -378,8 +517,8 @@ export {
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
pruneDraftPrompts, pruneDraftPrompts,
withSession, withSession,
setSessionCompactionState,
setSessionPendingPermission, setSessionPendingPermission,
setSessionStatus,
setActiveSession, setActiveSession,
setActiveParentSession, setActiveParentSession,

View File

@@ -1,165 +1,23 @@
import type { Session, SessionStatus } from "../types/session" import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null { function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) ?? null return instanceSessions?.get(sessionId) ?? null
} }
function isSessionCompacting(session: Session): boolean {
const time = (session.time as (Session["time"] & { compacting?: number }) | undefined)
const compactingFlag = time?.compacting
if (typeof compactingFlag === "number") {
return compactingFlag > 0
}
return Boolean(compactingFlag)
}
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
let latest: MessageInfo | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const id of messageIds) {
const info = store.getMessageInfo(id)
if (!info) continue
if (role && info.role !== role) continue
const timestamp = info.time?.created ?? 0
if (timestamp >= latestTimestamp) {
latest = info
latestTimestamp = timestamp
}
}
return latest
}
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
const store = messageStoreBus.getOrCreate(instanceId)
const messageIds = store.getSessionMessageIds(sessionId)
let latest: MessageRecord | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const id of messageIds) {
const record = store.getMessage(id)
if (!record) continue
const info = store.getMessageInfo(id)
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
if (timestamp >= latestTimestamp) {
latest = record
latestTimestamp = timestamp
}
}
return latest
}
function getInfoCreatedTimestamp(info?: MessageInfo): number {
if (!info) {
return Number.NEGATIVE_INFINITY
}
const created = info.time?.created
if (typeof created === "number" && Number.isFinite(created)) {
return created
}
return Number.NEGATIVE_INFINITY
}
function getAssistantCompletionTimestamp(info?: MessageInfo): number {
if (!info) {
return Number.NEGATIVE_INFINITY
}
const completed = (info.time as { completed?: number } | undefined)?.completed
if (typeof completed === "number" && Number.isFinite(completed)) {
return completed
}
return Number.NEGATIVE_INFINITY
}
function isAssistantInfoPending(info?: MessageInfo): boolean {
if (!info) {
return false
}
const completed = (info.time as { completed?: number } | undefined)?.completed
if (completed === undefined || completed === null) {
return true
}
const created = getInfoCreatedTimestamp(info)
return completed < created
}
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
if (record.role !== "assistant") {
return false
}
if (record.status === "error") {
return false
}
if (record.status === "streaming" || record.status === "sending") {
return true
}
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
if (completedAt !== undefined && completedAt !== null) {
return false
}
return !(record.status === "complete" || record.status === "sent")
}
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus { export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
const session = getSession(instanceId, sessionId) const session = getSession(instanceId, sessionId)
if (!session) { if (!session) {
return "idle" return "idle"
} }
return session.status ?? "idle"
}
const store = messageStoreBus.getOrCreate(instanceId) export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) { export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {
return "compacting" return getInstanceSessionIndicatorStatusCached(instanceId)
}
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user")
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant")
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
if (!lastRecord) {
const latestInfo = latestUserInfo ?? latestAssistantInfo
if (!latestInfo) {
return "idle"
}
if (latestInfo.role === "user") {
return "working"
}
const infoCompleted = latestInfo.time?.completed
return infoCompleted ? "idle" : "working"
}
if (lastRecord.role === "user") {
return "working"
}
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
return "working"
}
if (isAssistantInfoPending(latestAssistantInfo)) {
return "working"
}
const userTimestamp = getInfoCreatedTimestamp(latestUserInfo)
const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo)
if (userTimestamp > assistantCompletedAt) {
return "working"
}
return "idle"
} }
export function isSessionBusy(instanceId: string, sessionId: string): boolean { export function isSessionBusy(instanceId: string, sessionId: string): boolean {

View File

@@ -26,6 +26,7 @@ import {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
setSessionStatus,
} from "./session-state" } from "./session-state"
import { getDefaultModel } from "./session-models" import { getDefaultModel } from "./session-models"
@@ -56,6 +57,7 @@ import {
handleSessionCompacted, handleSessionCompacted,
handleSessionError, handleSessionError,
handleSessionIdle, handleSessionIdle,
handleSessionStatus,
handleSessionUpdate, handleSessionUpdate,
handleTuiToast, handleTuiToast,
} from "./session-events" } from "./session-events"
@@ -68,6 +70,7 @@ sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionError = handleSessionError sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle sseManager.onSessionIdle = handleSessionIdle
sseManager.onSessionStatus = handleSessionStatus
sseManager.onTuiToast = handleTuiToast sseManager.onTuiToast = handleTuiToast
sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionUpdated = handlePermissionUpdated
sseManager.onPermissionReplied = handlePermissionReplied sseManager.onPermissionReplied = handlePermissionReplied
@@ -109,6 +112,7 @@ export {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
setSessionStatus,
updateSessionAgent, updateSessionAgent,
updateSessionModel, updateSessionModel,
} }

View File

@@ -208,6 +208,35 @@
border-radius: 0; border-radius: 0;
} }
.message-compaction-card {
@apply flex flex-col gap-1 px-3 py-2 text-xs;
background-color: var(--message-assistant-bg);
}
.message-compaction-card--auto {
background-color: var(--session-status-compacting-bg);
color: var(--session-status-compacting-fg);
}
.message-compaction-card--manual {
background-color: var(--message-user-bg);
color: var(--text-primary);
}
.message-compaction-row {
@apply flex items-center gap-2;
justify-content: center;
}
.message-compaction-icon {
@apply inline-flex items-center;
color: inherit;
}
.message-compaction-label {
font-weight: var(--font-weight-medium);
}
.message-step-start { .message-step-start {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-left: 4px solid var(--message-assistant-border);

View File

@@ -146,6 +146,16 @@
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
} }
.message-timeline-compaction-auto {
border-color: var(--session-status-compacting-fg);
background-color: var(--surface-secondary);
}
.message-timeline-compaction-manual {
border-color: var(--message-user-border);
background-color: var(--message-user-bg);
}
.message-timeline-segment-active { .message-timeline-segment-active {
background-color: #0f5b44 !important; background-color: #0f5b44 !important;
border-color: transparent !important; border-color: transparent !important;
@@ -158,8 +168,12 @@
pointer-events: none; pointer-events: none;
} }
.message-timeline-label-short { .message-timeline-label-full {
display: none; display: none;
}
.message-timeline-label-short {
display: inline-flex;
line-height: 1; line-height: 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -170,15 +184,6 @@
height: 1rem; height: 1rem;
} }
@media (max-width: 720px) {
.message-timeline-label-full {
display: none;
}
.message-timeline-label-short {
display: inline-flex;
}
}
.message-timeline-tooltip { .message-timeline-tooltip {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;

View File

@@ -42,8 +42,8 @@
--session-status-compacting-bg: rgba(109, 40, 217, 0.18); --session-status-compacting-bg: rgba(109, 40, 217, 0.18);
--session-status-idle-fg: #15803d; --session-status-idle-fg: #15803d;
--session-status-idle-bg: rgba(22, 163, 74, 0.16); --session-status-idle-bg: rgba(22, 163, 74, 0.16);
--session-status-permission-fg: #c2410c; --session-status-permission-fg: #b91c1c;
--session-status-permission-bg: rgba(251, 191, 36, 0.25); --session-status-permission-bg: rgba(239, 68, 68, 0.16);
--list-item-highlight-bg: rgba(0, 102, 255, 0.1); --list-item-highlight-bg: rgba(0, 102, 255, 0.1);
--list-item-highlight-bg-solid: #e5f0ff; --list-item-highlight-bg-solid: #e5f0ff;
--list-item-highlight-border: rgba(0, 102, 255, 0.25); --list-item-highlight-border: rgba(0, 102, 255, 0.25);
@@ -191,8 +191,8 @@
--session-status-compacting-bg: rgba(192, 132, 252, 0.28); --session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80; --session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22); --session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24; --session-status-permission-fg: #f87171;
--session-status-permission-bg: rgba(251, 191, 36, 0.35); --session-status-permission-bg: rgba(248, 113, 113, 0.22);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2); --list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-bg-solid: #15324e; --list-item-highlight-bg-solid: #15324e;
--list-item-highlight-border: rgba(0, 128, 255, 0.4); --list-item-highlight-border: rgba(0, 128, 255, 0.4);
@@ -345,8 +345,8 @@
--session-status-compacting-bg: rgba(192, 132, 252, 0.28); --session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80; --session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22); --session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24; --session-status-permission-fg: #f87171;
--session-status-permission-bg: rgba(251, 191, 36, 0.35); --session-status-permission-bg: rgba(248, 113, 113, 0.22);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2); --list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-bg-solid: #15324e; --list-item-highlight-bg-solid: #15324e;
--list-item-highlight-border: rgba(0, 128, 255, 0.4); --list-item-highlight-border: rgba(0, 128, 255, 0.4);

View File

@@ -133,3 +133,12 @@
.kbd-separator { .kbd-separator {
@apply opacity-50; @apply opacity-50;
} }
/* Prevent iOS Safari auto-zoom on text input focus */
@media (pointer: coarse) {
input[type="text"],
input:not([type]),
textarea {
font-size: 16px !important;
}
}

View File

@@ -1,5 +1,5 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk" import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk/v2"
export interface LogEntry { export interface LogEntry {
timestamp: number timestamp: number
@@ -25,6 +25,7 @@ export interface InstanceMetadata {
project?: ProjectInfo | null project?: ProjectInfo | null
mcpStatus?: RawMcpStatus | null mcpStatus?: RawMcpStatus | null
lspStatus?: LspStatus[] | null lspStatus?: LspStatus[] | null
plugins?: string[] | null
version?: string version?: string
} }

View File

@@ -6,9 +6,10 @@ import type {
EventMessagePartRemoved as MessagePartRemovedEvent, EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart, Part as SDKPart,
Message as SDKMessage, Message as SDKMessage,
Permission,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { PermissionRequestLike } from "./permission"
// Re-export for other modules // Re-export for other modules
export type { export type {
MessageUpdateEvent, MessageUpdateEvent,
@@ -27,7 +28,7 @@ export interface RenderCache {
} }
export interface PendingPermissionState { export interface PendingPermissionState {
permission: Permission permission: PermissionRequestLike
active: boolean active: boolean
} }

View File

@@ -0,0 +1,131 @@
export type PermissionReply = "once" | "always" | "reject"
export interface PermissionToolRefLike {
messageID?: string
messageId?: string
callID?: string
callId?: string
}
// Compat type that covers both the legacy Permission.Info payload and the new
// PermissionNext.Request payload.
export interface PermissionRequestLike {
id: string
// Legacy fields
type?: string
pattern?: string
title?: string
sessionID?: string
messageID?: string
messageId?: string
callID?: string
callId?: string
metadata?: Record<string, unknown>
time?: { created?: number }
// New fields
permission?: string
patterns?: string[]
always?: string[]
tool?: PermissionToolRefLike
}
export interface PermissionReplyEventPropertiesLike {
sessionID?: string
sessionId?: string
permissionID?: string
permissionId?: string
requestID?: string
requestId?: string
response?: PermissionReply
reply?: PermissionReply
}
export function getPermissionId(permission: PermissionRequestLike | null | undefined): string {
return permission?.id ?? ""
}
export function getPermissionSessionId(permission: PermissionRequestLike | null | undefined): string | undefined {
return (
(permission as any)?.sessionID ??
(permission as any)?.sessionId ??
undefined
)
}
export function getPermissionMessageId(permission: PermissionRequestLike | null | undefined): string | undefined {
const tool = (permission as any)?.tool as PermissionToolRefLike | undefined
return (
tool?.messageID ??
tool?.messageId ??
(permission as any)?.messageID ??
(permission as any)?.messageId ??
undefined
)
}
export function getPermissionCallId(permission: PermissionRequestLike | null | undefined): string | undefined {
const tool = (permission as any)?.tool as PermissionToolRefLike | undefined
const metadata = (permission as any)?.metadata || {}
return (
tool?.callID ??
tool?.callId ??
(permission as any)?.callID ??
(permission as any)?.callId ??
(permission as any)?.toolCallID ??
(permission as any)?.toolCallId ??
metadata.callID ??
metadata.callId ??
undefined
)
}
export function getPermissionCreatedAt(permission: PermissionRequestLike | null | undefined): number {
const created = (permission as any)?.time?.created
return typeof created === "number" ? created : Date.now()
}
export function getPermissionKind(permission: PermissionRequestLike | null | undefined): string {
return (
(permission as any)?.permission ??
(permission as any)?.type ??
"permission"
)
}
export function getPermissionPatterns(permission: PermissionRequestLike | null | undefined): string[] {
const patterns = (permission as any)?.patterns
if (Array.isArray(patterns)) {
return patterns.filter((value) => typeof value === "string")
}
const pattern = (permission as any)?.pattern
if (typeof pattern === "string" && pattern.length > 0) {
return [pattern]
}
return []
}
export function getPermissionDisplayTitle(permission: PermissionRequestLike | null | undefined): string {
const title = (permission as any)?.title
if (typeof title === "string" && title.trim().length > 0) {
return title
}
const kind = getPermissionKind(permission)
const patterns = getPermissionPatterns(permission)
if (patterns.length > 0) {
return `${kind}: ${patterns.join(", ")}`
}
return kind
}
export function getRequestIdFromPermissionReply(properties: PermissionReplyEventPropertiesLike | null | undefined): string | undefined {
return (
(properties as any)?.requestID ??
(properties as any)?.requestId ??
(properties as any)?.permissionID ??
(properties as any)?.permissionId ??
undefined
)
}

View File

@@ -4,6 +4,7 @@ import type {
Provider as SDKProvider, Provider as SDKProvider,
Model as SDKModel, Model as SDKModel,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use // Export SDK types for external use
export type { export type {
@@ -15,6 +16,15 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting" export type SessionStatus = "idle" | "working" | "compacting"
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") {
return "idle"
}
// "busy" and "retry" both mean there's active work.
return "working"
}
// Our client-specific Session interface extending SDK Session // Our client-specific Session interface extending SDK Session
export interface Session export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> { extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
@@ -27,6 +37,7 @@ export interface Session
} }
version: string // Include version from SDK Session version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission pendingPermission?: boolean // Indicates if session is waiting on user permission
status: SessionStatus // Single source of truth for session status
} }
// Adapter function to convert SDK Session to client Session // Adapter function to convert SDK Session to client Session
@@ -35,6 +46,7 @@ export function createClientSession(
instanceId: string, instanceId: string,
agent: string = "", agent: string = "",
model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }, model: { providerId: string; modelId: string } = { providerId: "", modelId: "" },
status: SessionStatus = "idle",
): Session { ): Session {
return { return {
...sdkSession, ...sdkSession,
@@ -42,6 +54,7 @@ export function createClientSession(
parentId: sdkSession.parentID || null, parentId: sdkSession.parentID || null,
agent, agent,
model, model,
status,
} }
} }