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:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
description: "Version to apply to workspace packages (release builds)"
required: false
default: ""
type: string
tag:
description: "Git tag to upload assets to"
required: true
description: "Git tag to upload assets to (release builds)"
required: false
default: ""
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
required: false
default: ""
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:
id-token: write
contents: write
# Permissions are intentionally omitted here so callers can choose
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
@@ -41,10 +53,11 @@ jobs:
cache: npm
- 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
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
@@ -53,6 +66,7 @@ jobs:
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -79,11 +93,12 @@ jobs:
cache: npm
- 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
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ jobs:
cache: npm
- 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
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -157,18 +175,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- 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
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -180,6 +218,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- 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
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -232,6 +291,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- 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
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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)
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)
if: ${{ inputs.upload }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -287,6 +369,7 @@ jobs:
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,18 +412,38 @@ jobs:
librsvg2-dev
- 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
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -367,6 +470,7 @@ jobs:
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -429,7 +533,7 @@ jobs:
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
@@ -497,10 +601,11 @@ jobs:
sudo gem install --no-document fpm
- 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
- name: Install project dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
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
- name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob

View File

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

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ release/
.vite/
.electron-vite/
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",
"version": "0.4.0",
"version": "0.5.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
},
"scripts": {
@@ -23,5 +26,8 @@
"dependencies": {
"7zip-bin": "^5.2.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",
"version": "0.4.0",
"version": "0.5.1",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
@@ -69,6 +69,10 @@
"!icon.icns",
"!icon.ico"
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
}
],
"mac": {

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import path, { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) {
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 = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
env,
}
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",
"version": "0.4.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"version": "0.5.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"version": "0.5.1",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
@@ -16,10 +16,11 @@
"codenomad": "dist/bin.js"
},
"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",
"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"
},
"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
}
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 {
Preferences,
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")
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 = {
httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -150,6 +134,24 @@ async function main() {
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({
currentVersion: packageJson.version,
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 { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
interface HttpServerDeps {
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 })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
@@ -110,6 +119,8 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
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) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
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) => {

View File

@@ -1,5 +1,6 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -16,6 +20,7 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -23,9 +28,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -97,10 +104,17 @@ export class WorkspaceManager {
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 {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
@@ -108,6 +122,8 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -233,6 +249,161 @@ export class WorkspaceManager {
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 }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return

View File

@@ -13,7 +13,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,15 +30,45 @@ export class WorkspaceRuntime {
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)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
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) => {
const commandLine = [options.binaryPath, ...args].join(" ")
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",
)
const child = spawn(options.binaryPath, args, {
@@ -83,11 +113,22 @@ export class WorkspaceRuntime {
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
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))
} 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)
this.processes.delete(options.workspaceId)
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)
}
@@ -109,18 +154,25 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? ""
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)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
stopWarningTimer()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
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() ?? ""
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)
}
})

View File

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

View File

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

View File

@@ -21,11 +21,9 @@ import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
import { instances as instanceStore } from "./stores/instances"
import { useConfig } from "./stores/preferences"
import {
createInstance,
@@ -65,7 +63,12 @@ const App: Component = () => {
setThinkingBlocksExpansion,
} = useConfig()
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 [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
@@ -105,14 +108,30 @@ const App: Component = () => {
})
const launchErrorPath = () => {
const value = launchErrorBinary()
const value = launchError()?.binaryPath
if (!value) return "opencode"
return value.trim() || "opencode"
}
const isMissingBinaryError = (error: unknown): boolean => {
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
const launchErrorMessage = () => launchError()?.message ?? ""
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()
return (
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) {
if (!folderPath) {
@@ -135,7 +154,6 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
@@ -144,10 +162,13 @@ const App: Component = () => {
port: instances().get(instanceId)?.port,
})
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message)
setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
@@ -191,9 +212,6 @@ const App: Component = () => {
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function handleNewSession(instanceId: string) {
@@ -304,7 +322,7 @@ const App: Component = () => {
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchErrorBinary())} modal>
<Dialog open={Boolean(launchError())} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -312,8 +330,8 @@ const App: Component = () => {
<div>
<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">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
@@ -322,10 +340,23 @@ const App: Component = () => {
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</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">
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
Open Advanced Settings
</button>
<Show when={launchError()?.missingBinary}>
<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}>
Close
</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 { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
const inlineLoadedLanguages = new Set<string>()
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const success = await copyToClipboard(props.code)
if (success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (

View File

@@ -6,7 +6,7 @@ import { getLogger } from "../lib/logger"
const log = getLogger("session")
type ServiceSection = "lsp" | "mcp"
type ServiceSection = "lsp" | "mcp" | "plugins"
interface InstanceServiceStatusProps {
sections?: ServiceSection[]
@@ -51,20 +51,25 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
})
const isLoading = metadataContext?.isLoading ?? (() => false)
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 includeMcp = createMemo(() => sections().includes("mcp"))
const includePlugins = createMemo(() => sections().includes("plugins"))
const showHeadings = () => props.showSectionHeadings !== false
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
const metadata = createMemo(() => metadataAccessor())
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
const plugins = createMemo(() => metadata()?.plugins ?? [])
const isLspLoading = () => isLoading() || !hasLspMetadata()
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
@@ -85,9 +90,9 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
setPendingMcpAction(serverName, action)
try {
if (shouldEnable) {
await client.mcp.connect({ path: { name: serverName } })
await client.mcp.connect({ name: serverName })
} else {
await client.mcp.disconnect({ path: { name: serverName } })
await client.mcp.disconnect({ name: serverName })
}
await refreshMetadata()
} catch (error) {
@@ -213,10 +218,35 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
</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 (
<div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
</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 { FolderOpen, X } from "lucide-solid"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
interface InstanceTabProps {
instance: Instance
@@ -26,6 +27,24 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
}
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 (
<div class="group">
<button
@@ -40,7 +59,18 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
{props.instance.folder.split("/").pop() || props.instance.folder}
</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) => {
e.stopPropagation()
props.onClose()

View File

@@ -12,7 +12,7 @@ import {
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
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 Box from "@suid/material/Box"
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 type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import type { BackgroundProcess } from "../../../../server/src/api-types"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
@@ -56,6 +57,9 @@ import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
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 {
SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction,
@@ -128,7 +132,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [resizeStartX, setResizeStartX] = 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))
@@ -152,6 +164,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
persistPinState(side, value)
}
createEffect(() => {
const instanceId = props.instance.id
loadBackgroundProcesses(instanceId).catch((error) => {
log.warn("Failed to load background processes", error)
})
})
createEffect(() => {
switch (layoutMode()) {
case "desktop": {
@@ -314,6 +333,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return state
})
const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id))
const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => {
const status = connectionStatus()
@@ -326,6 +347,32 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
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 instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
@@ -853,18 +900,73 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
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 = [
{
id: "lsp",
label: "LSP Servers",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
id: "plan",
label: "Plan",
render: renderPlanSectionContent,
},
{
id: "background-processes",
label: "Background Shells",
render: renderBackgroundProcesses,
},
{
id: "mcp",
@@ -879,9 +981,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
),
},
{
id: "plan",
label: "Plan",
render: renderPlanSectionContent,
id: "lsp",
label: "LSP Servers",
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()}
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 { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
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) {
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
function resolvePartVersion(part: TextPart, text: string): string {
if (typeof part.version === "number") {
return String(part.version)
}
return `text-${hashText(text)}`
}
interface MarkdownProps {
part: TextPart
instanceId?: string
sessionId?: string
isDark?: boolean
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
@@ -27,33 +42,64 @@ export function Markdown(props: MarkdownProps) {
Promise.resolve().then(() => props.onRendered?.())
}
createEffect(async () => {
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
const dark = Boolean(props.isDark)
const themeKey = dark ? "dark" : "light"
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
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
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === themeKey && cache.mode === version
}
const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) {
if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html)
notifyRendered()
return
}
const globalCache = markdownRenderCache.get(cacheKey)
if (globalCache && globalCache.text === text) {
const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered()
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) {
part.renderCache = undefined
@@ -61,20 +107,12 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
commitCacheEntry(text)
}
}
return
@@ -82,22 +120,13 @@ export function Markdown(props: MarkdownProps) {
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
commitCacheEntry(text)
}
}
})
@@ -112,13 +141,20 @@ export function Markdown(props: MarkdownProps) {
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
copyText.textContent = "Copied!"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
if (success) {
copyText.textContent = "Copied!"
setTimeout(() => {
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)
// Register listener for language loading completion
const cleanupLanguageListener = onLanguagesLoaded(async () => {
if (props.disableHighlight) {
return
}
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
const { part, text, themeKey, version } = resolved()
if (latestRequestedText !== text) {
return
@@ -143,9 +176,10 @@ export function Markdown(props: MarkdownProps) {
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
part.renderCache = { text, html: rendered, theme: themeKey }
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
} catch (error) {

View File

@@ -1,4 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -192,7 +193,15 @@ type ReasoningDisplayItem = {
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 {
record: MessageRecord
@@ -330,6 +339,21 @@ export default function MessageBlock(props: MessageBlockProps) {
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") {
flushContent()
return
@@ -453,7 +477,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.key}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
@@ -477,6 +501,9 @@ export default function MessageBlock(props: MessageBlockProps) {
borderColor={(item as StepDisplayItem).accentColor}
/>
</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"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
@@ -505,6 +532,29 @@ interface StepCardProps {
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) {
const timestamp = () => {
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 type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
interface MessageItemProps {
record: MessageRecord
@@ -15,9 +16,9 @@ interface MessageItemProps {
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
onContentRendered?: () => void
}
}
export default function MessageItem(props: MessageItemProps) {
export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user"
@@ -155,8 +156,8 @@ interface MessageItemProps {
const handleCopy = async () => {
const content = getRawContent()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
const success = await copyToClipboard(content)
setCopied(success)
setTimeout(() => setCopied(false), 2000)
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
import type { Session, SessionStatus } from "../types/session"
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 Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
@@ -10,6 +10,7 @@ import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading, renameSession } from "../stores/sessions"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
@@ -72,14 +73,14 @@ const SessionList: Component<SessionListProps> = (props) => {
const copySessionId = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
try {
if (typeof navigator === "undefined" || !navigator.clipboard) {
throw new Error("Clipboard API unavailable")
const success = await copyToClipboard(sessionId)
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) {
log.error(`Failed to copy session ID ${sessionId}:`, error)
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
@@ -171,7 +172,11 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
<div class="session-item-row session-item-meta">
<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()}
</span>
<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 { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
const log = getLogger("session")
@@ -39,6 +40,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return getSessionBusyStatus(props.instanceId, currentSession.id)
})
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return
requestAnimationFrame(() => {
@@ -121,14 +123,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!instance || !instance.client) return
try {
await instance.client.session.revert({
path: { id: props.sessionId },
body: { messageID: messageId },
})
await requestData(
instance.client.session.revert({
sessionID: props.sessionId,
messageID: messageId,
}),
"session.revert",
)
const restoredText = getUserMessageText(messageId)
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
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))
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
@@ -194,7 +199,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const activeSession = sessionAccessor()
if (!activeSession) return null
return (
<div class="session-view">
<div ref={rootRef} class="session-view">
<MessageSection
instanceId={props.instanceId}
sessionId={activeSession.id}

View File

@@ -7,12 +7,14 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances"
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
import type { TextPart, RenderCache } from "../types/message"
import { resolveToolRenderer } from "./tool-call/renderers"
import type {
DiffPayload,
DiffRenderOptions,
MarkdownRenderOptions,
AnsiRenderOptions,
ToolCallPart,
ToolRendererContext,
ToolScrollHelpers,
@@ -20,11 +22,15 @@ import type {
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { escapeHtml } from "../lib/markdown"
const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk").ToolState
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
@@ -217,7 +223,13 @@ export default function ToolCall(props: ToolCallProps) {
const { isDark } = useTheme()
const toolCallMemo = createMemo(() => props.toolCall)
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 cacheContext = createMemo(() => ({
@@ -228,21 +240,36 @@ export default function ToolCall(props: ToolCallProps) {
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({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: TOOL_CALL_CACHE_SCOPE,
key: () => {
cacheId: () => {
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 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 pendingPermission = createMemo(() => {
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) {
if (!options.content) {
return null
@@ -639,14 +735,13 @@ export default function ToolCall(props: ToolCallProps) {
)
}
const markdownPart: TextPart = { type: "text", text: options.content }
const cached = markdownCache.get<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
const partId = toolCallMemo()?.id
if (!partId) {
throw new Error("Tool call markdown requires a part id")
}
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered()
props.onContentRendered?.()
}
@@ -655,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown
part={markdownPart}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}
@@ -675,6 +772,7 @@ export default function ToolCall(props: ToolCallProps) {
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent,
renderAnsi: renderAnsiContent,
renderDiff: renderDiffContent,
scrollHelpers,
}
@@ -741,7 +839,7 @@ export default function ToolCall(props: ToolCallProps) {
setPermissionSubmitting(true)
setPermissionError(null)
try {
const sessionId = permission.sessionID || props.sessionId
const sessionId = getPermissionSessionId(permission) || props.sessionId
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (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-header">
<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 class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{permission.title}</code>
<code>{getPermissionDisplayTitle(permission)}</code>
</div>
<Show when={diffPayload}>
{(payload) => (

View File

@@ -20,7 +20,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · Timeout: ${timeoutLabel}`
},
renderBody({ toolState, renderMarkdown }) {
renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState()
if (!state || state.status === "pending") return null
@@ -36,9 +36,19 @@ export const bashRenderer: ToolRenderer = {
const parts = [command, outputResult?.text].filter(Boolean)
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
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 partVersionAccessor = () => undefined
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null
return {
@@ -57,6 +58,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown,
renderAnsi,
renderDiff,
scrollHelpers: undefined,
}

View File

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

View File

@@ -1,6 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
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 { getLogger } from "../lib/logger"
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 {
AppConfig,
BackgroundProcess,
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
@@ -28,6 +31,12 @@ const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
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 {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
@@ -39,9 +48,41 @@ function buildEventsUrl(base: string | undefined, path: string): string {
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 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>) {
if (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> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers: HeadersInit = {
"Content-Type": "application/json",
...(init?.headers ?? {}),
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined) {
headers["Content-Type"] = "application/json"
}
const method = (init?.method ?? "GET").toUpperCase()
@@ -186,6 +227,47 @@ export const serverApi = {
deleteInstanceData(id: string): Promise<void> {
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) {
sseLogger.info(`Connecting to ${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 {
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 CacheSessionMap = Map<string, CacheScopeMap>
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
if (value === undefined) {
const existingMap = getScopeValueMap(params, false)
existingMap?.delete(params.key)
existingMap?.delete(params.cacheId)
cleanupHierarchy(instanceKey, sessionKey, params.scope)
return
}
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 {
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 {

View File

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

View File

@@ -11,13 +11,13 @@ import {
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions")
@@ -240,16 +240,15 @@ export function useCommands(options: UseCommandsOptions) {
if (!session) return
try {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
await requestData(
instance.client.session.summarize({
sessionID: sessionId,
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
}),
"session.summarize",
)
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
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({
id: "undo",
label: "Undo Last Message",
@@ -316,10 +331,13 @@ export function useCommands(options: UseCommandsOptions) {
}
try {
await instance.client.session.revert({
path: { id: sessionId },
body: { messageID },
})
await requestData(
instance.client.session.revert({
sessionID: sessionId,
messageID,
}),
"session.revert",
)
if (!restoredText) {
const fallbackRecord = store.getMessage(messageID)
@@ -327,7 +345,7 @@ export function useCommands(options: UseCommandsOptions) {
}
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
const textarea = findVisiblePromptTextarea(sessionId)
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
@@ -381,7 +399,7 @@ export function useCommands(options: UseCommandsOptions) {
keywords: ["clear", "reset"],
shortcut: { key: "K", meta: true },
action: () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
const textarea = findVisiblePromptTextarea()
if (textarea) textarea.value = ""
},
})

View File

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

View File

@@ -8,7 +8,7 @@ const pendingMetadataRequests = new Set<string>()
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
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> {
@@ -30,15 +30,22 @@ export async function loadInstanceMetadata(instance: Instance, options?: { force
pendingMetadataRequests.add(instance.id)
try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
const [projectResult, mcpResult, lspResult, configResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instance.id),
client.config.get(),
])
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : 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 ?? {}) }
@@ -54,10 +61,15 @@ export async function loadInstanceMetadata(instance: Instance, options?: { force
updates.lspStatus = lspStatus ?? []
}
if (configResult.status === "fulfilled") {
updates.plugins = plugins ?? []
}
if (!updates?.version && instance.binaryVersion) {
updates.version = instance.binaryVersion
}
mergeInstanceMetadata(instance.id, updates)
} catch (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"
class SDKManager {
private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient {
if (this.clients.has(instanceId)) {
return this.clients.get(instanceId)!
const existing = this.clients.get(instanceId)
if (existing) {
return existing
}
const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client)
return client
}
@@ -29,6 +31,8 @@ class SDKManager {
}
}
export type { OpencodeClient }
function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)

View File

@@ -7,15 +7,16 @@ import {
} from "../types/message"
import type {
EventLspUpdated,
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk"
import { serverEvents } from "./server-events"
import type {
BackgroundProcess,
InstanceStreamEvent,
InstanceStreamStatus,
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 =
| MessageUpdateEvent
| MessageRemovedEvent
@@ -46,10 +61,12 @@ type SSEEvent =
| EventSessionCompacted
| EventSessionError
| EventSessionIdle
| EventPermissionUpdated
| EventPermissionReplied
| { type: "permission.updated" | "permission.asked"; properties?: any }
| { type: "permission.replied"; properties?: any }
| EventLspUpdated
| TuiToastEvent
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus
@@ -117,15 +134,25 @@ class SSEManager {
case "session.idle":
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break
case "session.status":
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
break
case "permission.updated":
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
case "permission.asked":
this.onPermissionUpdated?.(instanceId, event as any)
break
case "permission.replied":
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
this.onPermissionReplied?.(instanceId, event as any)
break
case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
case "background.process.updated":
this.onBackgroundProcessUpdated?.(instanceId, event as BackgroundProcessUpdatedEvent)
break
case "background.process.removed":
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
break
default:
log.warn("Unknown SSE event type", { type: event.type })
}
@@ -148,9 +175,12 @@ class SSEManager {
onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => 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>
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 type { Command as SDKCommand } from "@opencode-ai/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { requestData } from "../lib/opencode-api"
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
const response = await client.command.list()
const commands = response.data ?? []
const commands = await requestData<SDKCommand[]>(client.command.list(), "command.list").catch(() => [])
setCommandMap((prev) => {
const next = new Map(prev)
next.set(instanceId, commands)

View File

@@ -1,6 +1,9 @@
import { createSignal } from "solid-js"
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 { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
@@ -18,6 +21,7 @@ import { preferences } from "./preferences"
import { setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
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())
// 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 permissionSessionCounts = new Map<string, Map<string, number>>()
function syncHasInstancesFlag() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
setHasInstances(readyExists)
}
interface DisconnectedInstanceInfo {
id: string
folder: string
@@ -68,7 +77,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
updateInstance(descriptor.id, mapped)
} else {
addInstance(mapped)
setHasInstances(true)
}
if (descriptor.status === "ready") {
@@ -117,6 +125,37 @@ function releaseInstanceResources(instanceId: string) {
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) {
try {
await fetchSessions(instanceId)
@@ -126,6 +165,7 @@ async function hydrateInstanceData(instanceId: string) {
const instance = instances().get(instanceId)
if (!instance?.client) return
await fetchCommands(instanceId, instance.client)
await syncPendingPermissions(instanceId)
} catch (error) {
log.error("Failed to fetch initial data", error)
}
@@ -135,9 +175,6 @@ void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
workspaces.forEach((workspace) => upsertWorkspace(workspace))
if (workspaces.length === 0) {
setHasInstances(false)
}
} catch (error) {
log.error("Failed to load workspaces", error)
}
@@ -159,9 +196,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
case "workspace.stopped":
releaseInstanceResources(event.workspaceId)
removeInstance(event.workspaceId)
if (instances().size === 0) {
setHasInstances(false)
}
break
case "workspace.log":
handleWorkspaceLog(event.entry)
@@ -249,6 +283,7 @@ function addInstance(instance: Instance) {
})
ensureLogContainer(instance.id)
ensureLogStreamingState(instance.id)
syncHasInstancesFlag()
}
function updateInstance(id: string, updates: Partial<Instance>) {
@@ -260,6 +295,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
}
return next
})
syncHasInstancesFlag()
}
function removeInstance(id: string) {
@@ -301,6 +337,7 @@ function removeInstance(id: string) {
clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id)
syncHasInstancesFlag()
}
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
@@ -328,9 +365,6 @@ async function stopInstance(id: string) {
}
removeInstance(id)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
@@ -349,8 +383,7 @@ async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefin
return undefined
}
log.info("lsp.status", { instanceId })
const response = await lsp.status()
return response.data ?? []
return await requestData<LspStatus[]>(lsp.status(), "lsp.status")
}
function getActiveInstance(): Instance | null {
@@ -384,7 +417,7 @@ function clearLogs(id: string) {
}
// Permission management functions
function getPermissionQueue(instanceId: string): Permission[] {
function getPermissionQueue(instanceId: string): PermissionRequestLike[] {
const queue = permissionQueues().get(instanceId)
if (!queue) {
return []
@@ -431,7 +464,7 @@ function clearSessionPendingCounts(instanceId: string): void {
permissionSessionCounts.delete(instanceId)
}
function addPermissionToQueue(instanceId: string, permission: Permission): void {
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
let inserted = false
setPermissionQueues((prev) => {
@@ -442,7 +475,7 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
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)
inserted = true
return next
@@ -461,17 +494,19 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
})
const sessionId = getPermissionSessionId(permission)
incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
if (sessionId) {
incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
}
}
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
let removedPermission: Permission | null = null
let removedPermission: PermissionRequestLike | null = null
setPermissionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? []
const filtered: Permission[] = []
const filtered: PermissionRequestLike[] = []
for (const item of queue) {
if (item.id === permissionId) {
@@ -495,7 +530,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const next = new Map(prev)
const activeId = next.get(instanceId)
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)
}
return next
@@ -504,8 +539,10 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const removed = removedPermission
if (removed) {
const removedSessionId = getPermissionSessionId(removed)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
if (removedSessionId) {
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
}
}
@@ -523,15 +560,13 @@ function clearPermissionQueue(instanceId: string): void {
clearSessionPendingCounts(instanceId)
}
function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
async function sendPermissionResponse(
instanceId: string,
sessionId: string,
permissionId: string,
response: "once" | "always" | "reject"
requestId: string,
reply: PermissionReply
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
@@ -539,13 +574,16 @@ async function sendPermissionResponse(
}
try {
await instance.client.postSessionIdPermissionsPermissionId({
path: { id: sessionId, permissionID: permissionId },
body: { response },
})
await requestData(
instance.client.permission.reply({
requestID: requestId,
reply,
}),
"permission.reply",
)
// Remove from queue after successful response
removePermissionFromQueue(instanceId, permissionId)
removePermissionFromQueue(instanceId, requestId)
} catch (error) {
log.error("Failed to send permission response", error)
throw error
@@ -590,9 +628,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
log.error("Failed to stop disconnected instance", error)
} finally {
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 { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
@@ -107,42 +108,108 @@ export function replaceMessageIdV2(instanceId: string, oldId: string, newId: str
store.replaceMessageId({ oldId, newId })
}
function extractPermissionMessageId(permission: Permission): string | undefined {
return (permission as any).messageID || (permission as any).messageId
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {
return getPermissionMessageId(permission)
}
function extractPermissionPartId(permission: Permission): string | undefined {
function extractPermissionPartId(permission: PermissionRequestLike): string | undefined {
const metadata = (permission as any).metadata || {}
return (
(permission as any).callID ||
(permission as any).callId ||
(permission as any).toolCallID ||
(permission as any).toolCallId ||
metadata.partId ||
(permission as any).partID ||
(permission as any).partId ||
metadata.partID ||
metadata.callID ||
metadata.callId ||
metadata.partId ||
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
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({
permission,
messageId: extractPermissionMessageId(permission),
partId: extractPermissionPartId(permission),
messageId,
partId,
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 {
if (!permissionId) return
const store = messageStoreBus.getOrCreate(instanceId)
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 {
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -51,16 +51,8 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
return part.id
}
const toolCallId =
(part as any).callID ??
(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
if (part.type === "tool") {
throw new Error("Tool part missing id")
}
const fallbackId = `${messageId}-part-${index}`
@@ -191,6 +183,8 @@ export interface InstanceMessageStore {
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void
removeMessage: (messageId: string) => void
removeMessagePart: (messageId: string, partId: string) => void
bufferPendingPart: (entry: PendingPartEntry) => void
flushPendingParts: (messageId: string) => void
replaceMessageId: (options: ReplaceMessageIdOptions) => void
@@ -502,16 +496,62 @@ 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) {
const message = state.messages[input.messageId]
if (!message) {
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
@@ -520,7 +560,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
draft.partIds = [...draft.partIds, 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] = {
id: partId,
data: cloned,
@@ -533,6 +573,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}),
)
rebindPermissionForPart(input.messageId, partId, cloned)
if (isCompletedTodoPart(cloned)) {
recordLatestTodoSnapshot(message.sessionId, {
messageId: input.messageId,
@@ -540,12 +582,106 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
timestamp: Date.now(),
})
}
// Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes.
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) {
const pending = state.pendingParts[messageId]
@@ -644,7 +780,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function upsertPermission(entry: PermissionEntry) {
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? "__global__"
const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
setState(
"permissions",
@@ -868,8 +1004,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
addOrUpdateSession,
hydrateMessages,
upsertMessage,
applyPartUpdate,
bufferPendingPart,
applyPartUpdate,
removeMessage,
removeMessagePart,
bufferPendingPart,
flushPendingParts,
replaceMessageId,
setMessageInfo,

View File

@@ -26,35 +26,13 @@ function decodeTextSegment(segment: any): any {
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 {
if (!part || typeof part !== "object") {
return part
}
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") {
const inferredId = deriveToolPartId(part)
if (inferredId) {
part = { ...part, id: inferredId }
}
if (part.type === "tool" && (typeof part.id !== "string" || part.id.length === 0)) {
throw new Error("Tool part missing id")
}
if (part.type !== "text") {

View File

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

View File

@@ -1,5 +1,5 @@
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 MessageRole = "user" | "assistant"
@@ -47,7 +47,7 @@ export interface PendingPartEntry {
}
export interface PermissionEntry {
permission: Permission
permission: PermissionRequestLike
messageId?: string
partId?: string
enqueuedAt: number

View File

@@ -7,6 +7,7 @@ import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("actions")
@@ -179,17 +180,13 @@ async function sendMessage(
try {
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
const response = await instance.client.session.promptAsync({
path: { id: sessionId },
body: requestBody,
})
log.info("sendMessage response", response)
if (response.error) {
log.error("sendMessage server error", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
await requestData(
instance.client.session.promptAsync({
sessionID: sessionId,
...(requestBody as any),
}),
"session.promptAsync",
)
} catch (error) {
log.error("Failed to send prompt", error)
throw error
@@ -232,10 +229,13 @@ async function executeCustomCommand(
body.model = `${session.model.providerId}/${session.model.modelId}`
}
await instance.client.session.command({
path: { id: sessionId },
body,
})
await requestData(
instance.client.session.command({
sessionID: sessionId,
...(body as any),
}),
"session.command",
)
}
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"
await instance.client.session.shell({
path: { id: sessionId },
body: {
await requestData(
instance.client.session.shell({
sessionID: sessionId,
agent,
command,
},
})
}),
"session.shell",
)
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
@@ -270,9 +271,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
try {
log.info("session.abort", { instanceId, sessionId })
await instance.client.session.abort({
path: { id: sessionId },
})
await requestData(
instance.client.session.abort({
sessionID: sessionId,
}),
"session.abort",
)
log.info("abortSession complete", { instanceId, sessionId })
} catch (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")
}
await instance.client.session.update({
path: { id: sessionId },
body: { title: trimmedTitle },
})
await requestData(
instance.client.session.update({
sessionID: sessionId,
title: trimmedTitle,
}),
"session.update",
)
withSession(instanceId, sessionId, (current) => {
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 { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
activeSessionId,
agents,
@@ -23,14 +22,16 @@ import {
loading,
setLoading,
cleanupBlankSessions,
syncInstanceSessionIndicator,
} from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers"
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 { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("api")
@@ -77,10 +78,30 @@ async function fetchSessions(instanceId: string): Promise<void> {
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)
for (const apiSession of response.data) {
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, {
id: apiSession.id,
@@ -89,6 +110,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
status,
version: apiSession.version,
time: {
...apiSession.time,
@@ -112,6 +134,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
return next
})
syncInstanceSessionIndicator(instanceId, sessionMap)
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId)
@@ -127,11 +151,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
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()))
} catch (error) {
@@ -183,6 +202,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
parentId: null,
agent: selectedAgent,
model: defaultModel,
status: "idle",
version: response.data.version,
time: {
...response.data.time,
@@ -205,6 +225,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
return next
})
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || []
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
@@ -261,25 +283,16 @@ async function forkSession(
throw new Error("Instance not ready")
}
const request: {
path: { id: string }
body?: { messageID: string }
} = {
path: { id: sourceSessionId },
}
if (options?.messageId) {
request.body = { messageID: options.messageId }
const request: { sessionID: string; messageID?: string } = {
sessionID: sourceSessionId,
messageID: options?.messageId,
}
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const response = await instance.client.session.fork(request)
if (!response.data) {
throw new Error("Failed to fork session: No data returned")
}
const info = response.data as SessionForkResponse
const info = await requestData<SessionForkResponse>(
instance.client.session.fork(request),
"session.fork",
)
const forkedSession = {
id: info.id,
instanceId,
@@ -290,6 +303,7 @@ async function forkSession(
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
status: "idle",
version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert
@@ -310,6 +324,8 @@ async function forkSession(
return next
})
syncInstanceSessionIndicator(instanceId)
const instanceProviders = providers().get(instanceId) || []
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
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 {
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) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
if (instanceSessions.size === 0) {
next.delete(instanceId)
}
}
return next
})
setSessionCompactionState(instanceId, sessionId, false)
syncInstanceSessionIndicator(instanceId)
clearSessionDraftPrompt(instanceId, sessionId)
// Drop normalized message state and caches for this session
@@ -519,14 +539,30 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
try {
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
}
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 role = info.role || "assistant"
const messageId = info.id || String(Date.now())
@@ -552,8 +588,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
let providerID = ""
let modelID = ""
for (let i = response.data.length - 1; i >= 0; i--) {
const apiMessage = response.data[i]
for (let i = apiMessages.length - 1; i >= 0; i--) {
const apiMessage = apiMessages[i]
const info = apiMessage.info || apiMessage
if (info.role === "assistant") {
@@ -574,19 +610,23 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
setSessions((prev) => {
const next = new Map(prev)
const nextInstanceSessions = next.get(instanceId)
if (nextInstanceSessions) {
const existingSession = nextInstanceSessions.get(sessionId)
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)
}
if (!nextInstanceSessions) {
return next
}
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
})
@@ -606,6 +646,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
}
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) {
log.error("Failed to load messages:", error)
throw error

View File

@@ -6,37 +6,43 @@ import type {
MessageUpdateEvent,
} from "../types/message"
import type {
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk"
import type { MessageStatus } from "./message-v2/types"
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 { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
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 { updateSessionInfo } from "./message-v2/session-info"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
import {
applyPartUpdateV2,
replaceMessageIdV2,
upsertMessageInfoV2,
upsertPermissionV2,
removeMessagePartV2,
removeMessageV2,
removePermissionV2,
setSessionRevertV2,
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
@@ -49,8 +55,98 @@ interface TuiToastEvent {
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"
function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant"
}
@@ -72,7 +168,6 @@ function findPendingMessageId(
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") {
const rawPart = event.properties?.part
@@ -87,10 +182,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
if (part.type === "compaction") {
ensureSessionStatus(instanceId, sessionId, "compacting")
}
const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
@@ -133,10 +228,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
withSession(instanceId, sessionId, (session) => {
session.time = { ...(session.time ?? {}), updated: Date.now() }
})
const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete"
@@ -174,12 +271,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
if (!info) return
const compactingFlag = info.time?.compacting
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
setSessionCompactionState(instanceId, info.id, isCompacting)
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const instanceSessions = sessions().get(instanceId) ?? new Map<string, Session>()
const existingSession = instanceSessions.get(info.id)
@@ -194,6 +286,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
providerId: "",
modelId: "",
},
status: "idle",
version: info.version || "0",
time: info.time
? { ...info.time }
@@ -201,15 +294,20 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
created: Date.now(),
updated: Date.now(),
},
} as any
} as Session
let updatedInstanceSessions: Map<string, Session> | undefined
setSessions((prev) => {
const next = new Map(prev)
const updated = new Map(prev.get(instanceId))
updated.set(newSession.id, newSession)
next.set(instanceId, updated)
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
instanceSessions.set(newSession.id, newSession)
next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
return next
})
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
log.info(`[SSE] New session created: ${info.id}`, newSession)
@@ -218,13 +316,10 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession.time,
...(info.time ?? {}),
}
if (!info.time?.updated) {
mergedTime.updated = Date.now()
}
const updatedSession = {
...existingSession,
title: info.title || existingSession.title,
status: existingSession.status ?? "idle",
time: mergedTime,
revert: info.revert
? {
@@ -236,37 +331,53 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
: existingSession.revert,
}
let updatedInstanceSessions: Map<string, Session> | undefined
setSessions((prev) => {
const next = new Map(prev)
const updated = new Map(prev.get(instanceId))
updated.set(existingSession.id, updatedSession)
next.set(instanceId, updated)
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
instanceSessions.set(existingSession.id, updatedSession)
next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
return next
})
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
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
if (!sessionId) return
ensureSessionStatus(instanceId, sessionId, "idle")
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 {
const sessionID = event.properties?.sessionID
if (!sessionID) return
log.info(`[SSE] Session compacted: ${sessionID}`)
setSessionCompactionState(instanceId, sessionID, false)
withSession(instanceId, sessionID, (session) => {
const time = { ...(session.time ?? {}) }
time.compacting = 0
session.time = time
})
const existing = sessions().get(instanceId)?.get(sessionID)
if (existing) {
withSession(instanceId, sessionID, (session) => {
session.status = "working"
})
} else {
ensureSessionStatus(instanceId, sessionID, "working")
}
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 {
const sessionID = event.properties?.sessionID
if (!sessionID) return
const { sessionID, messageID } = event.properties
if (!sessionID || !messageID) return
log.info(`[SSE] Message removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after removal", error))
log.info(`[SSE] Message removed from session ${sessionID}`, { messageID })
removeMessageV2(instanceId, messageID)
updateSessionInfo(instanceId, sessionID)
}
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
const { sessionID, messageID, partID } = event.properties
if (!sessionID || !messageID || !partID) return
log.info(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after part removal", error))
log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID })
removeMessagePartV2(instanceId, messageID, partID)
updateSessionInfo(instanceId, sessionID)
}
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 {
const permission = event.properties
function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void {
const permission = event?.properties as PermissionRequestLike | undefined
if (!permission) return
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
}
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
const { permissionID } = event.properties
if (!permissionID) return
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
const properties = event?.properties as PermissionReplyEventPropertiesLike | undefined
const requestId = getRequestIdFromPermissionReply(properties)
if (!requestId) return
log.info(`[SSE] Permission replied: ${permissionID}`)
removePermissionFromQueue(instanceId, permissionID)
removePermissionV2(instanceId, permissionID)
log.info(`[SSE] Permission replied: ${requestId}`)
removePermissionFromQueue(instanceId, requestId)
removePermissionV2(instanceId, requestId)
}
export {
@@ -364,6 +478,7 @@ export {
handleSessionCompacted,
handleSessionError,
handleSessionIdle,
handleSessionStatus,
handleSessionUpdate,
handleTuiToast,
}

View File

@@ -1,12 +1,13 @@
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 { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus"
import { instances } from "./instances"
import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
const log = getLogger("session")
@@ -39,6 +40,130 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(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) {
if (!instanceId || !sessionId) return
setMessagesLoaded((prev) => {
@@ -130,39 +255,44 @@ function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
})
}
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
updater(session)
const updatedSession = {
...session,
}
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void | boolean) {
let previousBucket: InstanceSessionIndicatorStatus | "idle" | null = null
let nextBucket: InstanceSessionIndicatorStatus | "idle" | null = null
let didUpdate = false
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 newInstanceSessions = new Map(instanceSessions)
newInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, newInstanceSessions)
next.set(instanceId, instanceSessions)
return next
})
}
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
withSession(instanceId, sessionId, (session) => {
const time = { ...(session.time ?? {}) }
time.compacting = isCompacting ? Date.now() : 0
session.time = time
})
if (didUpdate && previousBucket && nextBucket) {
adjustIndicatorCounts(instanceId, previousBucket, nextBucket)
}
}
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
withSession(instanceId, sessionId, (session) => {
if (session.pendingPermission === pending) return
if (session.pendingPermission === pending) return false
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 {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null
@@ -272,8 +409,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
}
let messages: any[] = []
try {
const response = await instance.client.session.messages({ path: { id: session.id } })
messages = response.data || []
messages = await requestData<any[]>(
instance.client.session.messages({ sessionID: session.id }),
"session.messages",
)
} catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession
@@ -378,8 +517,8 @@ export {
clearInstanceDraftPrompts,
pruneDraftPrompts,
withSession,
setSessionCompactionState,
setSessionPendingPermission,
setSessionStatus,
setActiveSession,
setActiveParentSession,

View File

@@ -1,165 +1,23 @@
import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId)
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 {
const session = getSession(instanceId, sessionId)
if (!session) {
return "idle"
}
return session.status ?? "idle"
}
const store = messageStoreBus.getOrCreate(instanceId)
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}
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 getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {
return getInstanceSessionIndicatorStatusCached(instanceId)
}
export function isSessionBusy(instanceId: string, sessionId: string): boolean {

View File

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

View File

@@ -208,6 +208,35 @@
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 {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);

View File

@@ -146,6 +146,16 @@
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 {
background-color: #0f5b44 !important;
border-color: transparent !important;
@@ -158,8 +168,12 @@
pointer-events: none;
}
.message-timeline-label-short {
.message-timeline-label-full {
display: none;
}
.message-timeline-label-short {
display: inline-flex;
line-height: 1;
align-items: center;
justify-content: center;
@@ -170,15 +184,6 @@
height: 1rem;
}
@media (max-width: 720px) {
.message-timeline-label-full {
display: none;
}
.message-timeline-label-short {
display: inline-flex;
}
}
.message-timeline-tooltip {
position: fixed;
z-index: 1000;

View File

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

View File

@@ -133,3 +133,12 @@
.kbd-separator {
@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 { LspStatus, Project as SDKProject } from "@opencode-ai/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk/v2"
export interface LogEntry {
timestamp: number
@@ -25,6 +25,7 @@ export interface InstanceMetadata {
project?: ProjectInfo | null
mcpStatus?: RawMcpStatus | null
lspStatus?: LspStatus[] | null
plugins?: string[] | null
version?: string
}

View File

@@ -6,9 +6,10 @@ import type {
EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart,
Message as SDKMessage,
Permission,
} from "@opencode-ai/sdk"
import type { PermissionRequestLike } from "./permission"
// Re-export for other modules
export type {
MessageUpdateEvent,
@@ -27,7 +28,7 @@ export interface RenderCache {
}
export interface PendingPermissionState {
permission: Permission
permission: PermissionRequestLike
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,
Model as SDKModel,
} from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use
export type {
@@ -15,6 +16,15 @@ export type {
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
export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
@@ -27,6 +37,7 @@ export interface Session
}
version: string // Include version from SDK Session
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
@@ -35,6 +46,7 @@ export function createClientSession(
instanceId: string,
agent: string = "",
model: { providerId: string; modelId: string } = { providerId: "", modelId: "" },
status: SessionStatus = "idle",
): Session {
return {
...sdkSession,
@@ -42,6 +54,7 @@ export function createClientSession(
parentId: sdkSession.parentID || null,
agent,
model,
status,
}
}