From 2ca118db59f9beda1a30ae7fe8b2ae67e0738133 Mon Sep 17 00:00:00 2001 From: René Date: Mon, 15 Dec 2025 15:32:06 +0100 Subject: docs: Fix Wakatime repository link in ecosystem.mdx (#5552) Co-authored-by: Github Action --- packages/web/src/content/docs/ecosystem.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages') diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index c62f12cb3..6a3119549 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -27,7 +27,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | | [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | | [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | -- cgit v1.2.3 From 274b86b19bcd2b6d27fb91212ebe29a0b1b3ba57 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:47:19 -0500 Subject: ci: fix AppImage build hanging by using portable appimage format - Add appimage target back to tauri.conf.json - Force reinstall tauri-cli from feat/truly-portable-appimage branch - Add 10 minute timeout to prevent indefinite hangs - Add logging to verify correct tauri-cli version is installed This fixes the issue where AppImage builds would hang at 'Running input plugin: gtk' by using the new portable appimage format that bypasses linuxdeploy entirely. --- .github/workflows/publish.yml | 7 ++++++- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) (limited to 'packages') diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c44efe1b..3120a02f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -166,10 +166,15 @@ jobs: GH_TOKEN: ${{ github.token }} # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage + - name: Install tauri-cli from portable appimage branch if: contains(matrix.settings.host, 'ubuntu') + run: | + cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + echo "Installed tauri-cli version:" + cargo tauri --version - name: Build and upload artifacts + timeout-minutes: 10 uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 6813a218b..e2d1f239a 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "nsis"], + "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, -- cgit v1.2.3 From b0f77da56c0238667c897d084f4c51eab263df6f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:58:23 -0500 Subject: core: reorganize agent configuration to separate primary agents (build, plan) from subagents --- .github/workflows/publish.yml | 2 +- packages/opencode/src/agent/agent.ts | 36 +++++++++++++++++----------------- packages/opencode/src/config/config.ts | 6 ++++++ 3 files changed, 25 insertions(+), 19 deletions(-) (limited to 'packages') diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3120a02f3..4e35af4e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-4vcpu-ubuntu-2204 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ef007df13..a2ff825d9 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -107,6 +107,24 @@ export namespace Agent { ) const result: Record = { + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, + permission: agentPermission, + mode: "primary", + native: true, + }, + plan: { + name: "plan", + options: {}, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + native: true, + }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -149,14 +167,6 @@ export namespace Agent { options: {}, permission: agentPermission, }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - native: true, - }, title: { name: "title", mode: "primary", @@ -177,16 +187,6 @@ export namespace Agent { prompt: PROMPT_SUMMARY, tools: {}, }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, - }, - mode: "primary", - native: true, - }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 333e19848..d0fb12c58 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -668,10 +668,16 @@ export namespace Config { .describe("@deprecated Use `agent` field instead."), agent: z .object({ + // primary plan: Agent.optional(), build: Agent.optional(), + // subagent general: Agent.optional(), explore: Agent.optional(), + // specialized + title: Agent.optional(), + summary: Agent.optional(), + compaction: Agent.optional(), }) .catchall(Agent) .optional() -- cgit v1.2.3 From f492122d590030d20ae8445596f77ebfc00eb9ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 14:59:05 +0000 Subject: chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ packages/sdk/openapi.json | 9 +++++++++ 2 files changed, 12 insertions(+) (limited to 'packages') diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c466e78dc..a92709561 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1413,6 +1413,9 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig [key: string]: AgentConfig | undefined } /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71f1df312..5a978c69c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7992,6 +7992,15 @@ }, "explore": { "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" } }, "additionalProperties": { -- cgit v1.2.3 From d2ce368a3fc15a740da097385990b60efb2b6e68 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 10:14:18 -0500 Subject: ci: update publish workflow concurrency to include version inputs and upgrade ubuntu runner to 24.04 --- .github/workflows/publish.yml | 4 ++-- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'packages') diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e35af4e2..4e3df621a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} permissions: id-token: write @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2204 + - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index e2d1f239a..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], + "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, -- cgit v1.2.3 From 56dde2cc835f509f77cbd800d080d6dbb2b8edc6 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 15 Dec 2025 16:01:15 +0000 Subject: release: v1.0.154 --- bun.lock | 30 +++++++++++++++--------------- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/tauri/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) (limited to 'packages') diff --git a/bun.lock b/bun.lock index c709bd83b..d7e87dd5e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -199,7 +199,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -215,7 +215,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.153", + "version": "1.0.154", "bin": { "opencode": "./bin/opencode", }, @@ -307,7 +307,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -327,7 +327,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -338,7 +338,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -351,7 +351,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -376,7 +376,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -411,7 +411,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "zod": "catalog:", }, @@ -422,7 +422,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4fcaff701..e3d371d3d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f68f70436..329ad92f9 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 53a41670d..b1aa5e080 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index a03e3843b..e9de718de 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0f5afeaa9..90f00980d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "description": "", "type": "module", "exports": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d0589587b..481b893ed 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 52f948655..124bb4433 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.153" +version = "1.0.154" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c2ee790ce..9320bedc5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bbeb9ae0a..7a63b3532 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.153", + "version": "1.0.154", "name": "opencode", "type": "module", "private": true, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4a7841908..509d00949 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a18040830..ac9e2f050 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 7a5c339e8..b38bad972 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 22fa35023..5cec6c0cd 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2632f6961..739953777 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/util/package.json b/packages/util/package.json index c77d99e7c..16149e204 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 9d028edbb..7c8ca723d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.153", + "version": "1.0.154", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6291f7177..753f8526f 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.153", + "version": "1.0.154", "publisher": "sst-dev", "repository": { "type": "git", -- cgit v1.2.3 From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:09:57 -0600 Subject: wip(desktop): progress --- packages/desktop/src/app.tsx | 45 ++-- packages/desktop/src/components/prompt-input.tsx | 203 ++++++++++++------ packages/desktop/src/context/command.tsx | 255 +++++++++++++++++++++++ packages/desktop/src/pages/session.tsx | 117 ++++++----- 4 files changed, 475 insertions(+), 145 deletions(-) create mode 100644 packages/desktop/src/context/command.tsx (limited to 'packages') diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a49dac9aa..6414d0d49 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" @@ -40,27 +41,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6ab280fa6 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" interface PromptInputProps { class?: string @@ -53,6 +54,14 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string +} + export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() @@ -61,18 +70,21 @@ export const PromptInput: Component = (props) => { const session = useSession() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + slashFilter: string }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + slashFilter: "", }) const MAX_HISTORY = 100 @@ -157,17 +169,17 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef.addEventListener("paste", handlePaste) + editorRef?.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef.removeEventListener("paste", handlePaste) + editorRef?.removeEventListener("paste", handlePaste) }) createEffect(() => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,6 +194,53 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) + // Get slash commands from registered commands (only those with explicit slash trigger) + const slashCommands = createMemo(() => + command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + })), + ) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + // Since slash commands only trigger from start, just clear the input + editorRef.innerHTML = "" + session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("popover", null) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: () => { + const filter = store.slashFilter.toLowerCase() + return slashCommands().filter( + (cmd) => + cmd.trigger.toLowerCase().includes(filter) || + cmd.title.toLowerCase().includes(filter) || + cmd.description?.toLowerCase().includes(filter) || + false, + ) + }, + key: (x) => x?.id, + onSelect: handleSlashSelect, + }) + + // Update slash filter when store changes + createEffect(() => { + slashOnInput(store.slashFilter) + }) + createEffect( on( () => session.prompt.current(), @@ -256,11 +315,17 @@ export const PromptInput: Component = (props) => { const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + // Slash commands only trigger when / is at the start of input + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + setStore("slashFilter", slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -294,8 +359,6 @@ export const PromptInput: Component = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,7 +398,7 @@ export const PromptInput: Component = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => @@ -403,8 +466,13 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + // Handle popover navigation + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } @@ -441,8 +509,8 @@ export const PromptInput: Component = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + if (store.popover) { + setStore("popover", null) } else if (session.working()) { abort() } @@ -470,31 +538,9 @@ export const PromptInput: Component = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = prompt.filter((part) => part.type === "file") - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection @@ -519,7 +565,6 @@ export const PromptInput: Component = (props) => { session.layout.setActiveTab(undefined) session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty editorRef.innerHTML = "" session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) @@ -542,38 +587,66 @@ export const PromptInput: Component = (props) => { return (
- + {/* Popover for file mentions and slash commands */} +
- 0} fallback={
No matching files
}> - - {(i) => ( - + )} + +
+ + + 0} + fallback={
No matching commands
} + > + + {(cmd) => ( +
-
-
- - )} - - + + )} + + + +
void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + + props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x.id} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.clear() + option.onSelect?.("palette") + } + }} + > + {(option) => ( +
+
+ {option.title} + + {option.description} + +
+ + {formatKeybind(option.keybind!)} + +
+ )} +
+
+ ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (dialog.stack.length === 0) { + dialog.replace(() => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + // Check for command palette keybind (mod+shift+p) + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + // Check registered command keybinds + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index bef12fbd8..e3cac4842 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { useCommand } from "@/context/command" export default function Page() { const layout = useLayout() @@ -41,6 +42,7 @@ export default function Page() { const sync = useSync() const session = useSession() const dialog = useDialog() + const command = useCommand() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -48,16 +50,6 @@ export default function Page() { }) let inputRef!: HTMLDivElement - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - createEffect(() => { if (layout.terminal.opened()) { if (session.terminal.all().length === 0) { @@ -66,35 +58,54 @@ export default function Page() { } }) - const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - dialog.replace(() => ) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "t") { - event.preventDefault() - const currentTheme = localStorage.getItem("theme") ?? "oc-1" - const themes = ["oc-1", "oc-2-paper"] - const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - localStorage.setItem("theme", nextTheme) - document.documentElement.setAttribute("data-theme", nextTheme) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - if (event.shiftKey) { - session.terminal.new() - return - } - layout.terminal.toggle() - return - } + // Register commands for this page + command.register(() => [ + { + id: "file.open", + title: "Open file", + description: "Search and open a file", + category: "File", + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.replace(() => ), + }, + { + id: "theme.toggle", + title: "Toggle theme", + description: "Switch between themes", + category: "View", + keybind: "ctrl+t", + slash: "theme", + onSelect: () => { + const currentTheme = localStorage.getItem("theme") ?? "oc-1" + const themes = ["oc-1", "oc-2-paper"] + const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] + localStorage.setItem("theme", nextTheme) + document.documentElement.setAttribute("data-theme", nextTheme) + }, + }, + { + id: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal", + category: "View", + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => layout.terminal.toggle(), + }, + { + id: "terminal.new", + title: "New terminal", + description: "Create a new terminal tab", + category: "Terminal", + keybind: "ctrl+shift+`", + onSelect: () => session.terminal.new(), + }, + ]) + // Handle keyboard events that aren't commands + const handleKeyDown = (event: KeyboardEvent) => { + // Don't interfere with terminal // @ts-expect-error if (document.activeElement?.dataset?.component === "terminal") { return @@ -108,32 +119,20 @@ export default function Page() { return } - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - + // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + const resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) -- cgit v1.2.3 From d66d806700de9ad9fb0f3997a076ad23a815e6ea Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:37:14 -0600 Subject: wip(desktop): progress --- packages/desktop/src/app.tsx | 11 +- .../desktop/src/components/dialog-select-file.tsx | 11 +- packages/desktop/src/components/prompt-input.tsx | 78 +++-- packages/desktop/src/components/terminal.tsx | 2 +- packages/desktop/src/context/command.tsx | 2 +- packages/desktop/src/context/layout.tsx | 92 +++++- packages/desktop/src/context/prompt.tsx | 100 +++++++ packages/desktop/src/context/session.tsx | 321 --------------------- packages/desktop/src/context/terminal.tsx | 106 +++++++ packages/desktop/src/pages/session.tsx | 184 ++++++++---- 10 files changed, 483 insertions(+), 424 deletions(-) create mode 100644 packages/desktop/src/context/prompt.tsx delete mode 100644 packages/desktop/src/context/session.tsx create mode 100644 packages/desktop/src/context/terminal.tsx (limited to 'packages') diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index 6414d0d49..2530f92dd 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { SessionProvider } from "@/context/session" +import { TerminalProvider } from "@/context/terminal" +import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -53,9 +54,11 @@ export function App() { path="/session/:id?" component={(p) => ( - - - + + + + + )} /> diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..b719e15d2 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } dialog.clear() }} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 6ab280fa6..a498593bd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -4,9 +4,10 @@ import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -67,12 +68,26 @@ export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() let editorRef!: HTMLDivElement + // Session-derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + const [store, setStore] = createStore<{ popover: "file" | "slash" | null historyIndex: number @@ -111,9 +126,9 @@ export const PromptInput: Component = (props) => { const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) @@ -149,9 +164,9 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -211,7 +226,7 @@ export const PromptInput: Component = (props) => { if (!cmd) return // Since slash commands only trigger from start, just clear the input editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -243,7 +258,7 @@ export const PromptInput: Component = (props) => { createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -255,7 +270,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part) => { + currentParts.forEach((part: ContentPart) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -333,7 +348,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -341,8 +356,8 @@ export const PromptInput: Component = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -403,7 +418,7 @@ export const PromptInput: Component = (props) => { const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { @@ -430,7 +445,7 @@ export const PromptInput: Component = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -481,7 +496,7 @@ export const PromptInput: Component = (props) => { const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) + const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 const isStart = cursorPos === 0 const isEnd = cursorPos === textLength @@ -511,7 +526,7 @@ export const PromptInput: Component = (props) => { if (event.key === "Escape") { if (store.popover) { setStore("popover", null) - } else if (session.working()) { + } else if (working()) { abort() } } @@ -519,18 +534,18 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") + const currentPrompt = prompt.current() + const text = currentPrompt.map((part: ContentPart) => part.content).join("") if (text.trim().length === 0) { - if (session.working()) abort() + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -539,7 +554,9 @@ export const PromptInput: Component = (props) => { if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") + const attachments = currentPrompt.filter( + (part: ContentPart) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) @@ -563,10 +580,9 @@ export const PromptInput: Component = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) sdk.client.session.prompt({ sessionID: existing.id, @@ -671,7 +687,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -703,7 +719,7 @@ export const PromptInput: Component = (props) => { inactive={!session.prompt.dirty() && !session.working()} value={ - +
Stop ESC @@ -720,8 +736,8 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx index b17a98270..26b03f980 100644 --- a/packages/desktop/src/context/command.tsx +++ b/packages/desktop/src/context/command.tsx @@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) { search={{ placeholder: "Search commands", autofocus: true }} emptyMessage="No commands found" items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} - key={(x) => x.id} + key={(x) => x?.id} groupBy={(x) => x.category ?? ""} onSelect={(option) => { if (option) { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..c3b3bbace --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,100 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export type ContentPart = TextPart | FileAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index e3cac4842..48e01239c 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -27,22 +27,91 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { useCommand } from "@/context/command" +import { useParams } from "@solidjs/router" +import { pipe, sumBy } from "remeda" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() + const prompt = usePrompt() const dialog = useDialog() const command = useCommand() + const params = useParams() + + // Session-specific derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + const lastUserMessage = createMemo(() => userMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + return userMessages()?.find((m) => m.id === messageStore.messageId) + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -50,10 +119,15 @@ export default function Page() { }) let inputRef!: HTMLDivElement + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) + }) + createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) @@ -99,7 +173,7 @@ export default function Page() { description: "Create a new terminal tab", category: "Terminal", keybind: "ctrl+shift+`", - onSelect: () => session.terminal.new(), + onSelect: () => terminal.new(), }, ]) @@ -166,11 +240,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -188,11 +262,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -210,8 +284,8 @@ export default function Page() { 1 && ( - session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + terminal.close(props.terminal.id)} /> ) } > @@ -326,7 +400,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return (
@@ -339,7 +413,7 @@ export default function Page() { > - +
@@ -349,15 +423,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - -
{session.usage.context() ?? 0}%
+ +
{context() ?? 0}%
- +
- - + +
Review
- +
- {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0}
- - - {(tab) => ( - - )} + + + {(tab) => }
@@ -415,27 +487,23 @@ export default function Page() { }} > - +
1 - ? "pr-6 pl-18" - : "px-6"), + (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"), }} />
@@ -476,7 +544,7 @@ export default function Page() {
- +
{ layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> @@ -506,7 +574,7 @@ export default function Page() {
- +
- + {(tab) => { const [file] = createResource( () => tab, @@ -579,7 +647,7 @@ export default function Page() {
- +
{ @@ -639,25 +707,21 @@ export default function Page() { > - + - t.id)}> - {(terminal) => } + t.id)}> + {(pty) => }
- +
- - {(terminal) => ( - - session.terminal.clone(terminal.id)} - /> + + {(pty) => ( + + terminal.clone(pty.id)} /> )} @@ -665,9 +729,9 @@ export default function Page() { {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - + {(t) => (
{t().title} -- cgit v1.2.3 From 3a14ca044ca521d1ab24c04da8e9e3bab9e2de58 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:43:01 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/local.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'packages') diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 56154c5ba..6ec9778cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() -- cgit v1.2.3 From acd91bddf7e83591d60f6801b7d34685a7f3f256 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:58:01 -0600 Subject: wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 5 +++++ packages/ui/src/components/dialog.tsx | 8 ++++++++ 2 files changed, 13 insertions(+) (limited to 'packages') diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 48e01239c..caea614c9 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -185,6 +185,11 @@ export default function Page() { return } + // Don't interfere with dialogs + if (dialog.stack.length > 0) { + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 47d6af42e..40a6ac83d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) { ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} + onOpenAutoFocus={(e) => { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} >
-- cgit v1.2.3 From ece3bfd93d88584dbab4e680a1cc56efc5113b9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:06:14 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/message-part.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'packages') diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9d4214bae..b66ef1d27 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -107,15 +107,19 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; width: 100%; [data-slot="message-part-title-area"] { + flex-grow: 1; display: flex; align-items: center; gap: 8px; + min-width: 0; } [data-slot="message-part-title"] { + flex-shrink: 0; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; @@ -128,14 +132,22 @@ [data-slot="message-part-path"] { display: flex; + flex-grow: 1; + min-width: 0; } [data-slot="message-part-directory"] { color: var(--text-weak); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; } [data-slot="message-part-filename"] { color: var(--text-strong); + flex-shrink: 0; } [data-slot="message-part-actions"] { -- cgit v1.2.3 From d81d63045ac619bc9201df4ae2e003a2d08596d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:18:39 -0600 Subject: wip(desktop): session turn state consolidation --- packages/ui/src/components/session-turn.tsx | 620 ++++++++++++++-------------- 1 file changed, 303 insertions(+), 317 deletions(-) (limited to 'packages') diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..e6654f480 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,6 +40,9 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + + if (!message()) return null + const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -49,379 +52,362 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [state, setState] = createStore({ + + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message()!.id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") + resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + lastStatusChange: Date.now(), + statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return + setStore("scrollY", scrollRef.scrollTop) + if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function handleInteraction() { if (working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return + setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setState("autoScrolling", false) + setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setState("userScrolled", false) + setStore("userScrolled", false) } }) createResizeObserver( - () => state.stickyTitleRef, + () => store.stickyTitleRef, ({ height }) => { - const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => state.stickyTriggerRef, + () => store.stickyTriggerRef, ({ height }) => { - const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) - return ( -
-
-
- - {(message) => { - const assistantMessages = createMemo(() => { - return messages()?.filter( - (m) => m.role === "assistant" && m.parentID == message().id, - ) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message().id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo( - () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, - ) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( - (m) => m.role === "assistant", - ) - resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - createEffect(() => { - lastPart() - scrollToBottom() - }) - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - }) + createEffect(() => { + lastPart() + scrollToBottom() + }) - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return - const timeSinceLastChange = Date.now() - lastStatusChange + const timeSinceLastChange = Date.now() - store.lastStatusChange - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + setStore("lastStatusChange", Date.now()) + if (store.statusTimeout) { + clearTimeout(store.statusTimeout) + setStore("statusTimeout", undefined) + } + } else { + if (store.statusTimeout) clearTimeout(store.statusTimeout) + setStore( + "statusTimeout", + setTimeout(() => { + setStore("status", rawStatus()) + setStore("lastStatusChange", Date.now()) + setStore("statusTimeout", undefined) + }, 2500 - timeSinceLastChange) as unknown as number, + ) + } + }) - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) - return ( -
- {/* Title (sticky) */} -
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + p?.id !== last()?.id)} /> + + + + - · - {store.duration} - - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - - -
-
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- ) - }} - + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
{props.children}
-- cgit v1.2.3 From 40572eeba4b94d411934c7ff95ce51ba88c9562f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:19:20 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/list.css | 1 + packages/ui/src/components/list.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'packages') diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index cd9e73d1d..368065e53 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -112,6 +112,7 @@ padding: 4px 10px; align-items: center; color: var(--text-strong); + scroll-margin-top: 28px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec6e159d..0ed745f32 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -79,7 +79,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + element?.scrollIntoView({ block: "center", behavior: "smooth" }) }) const handleSelect = (item: T | undefined, index: number) => { -- cgit v1.2.3 From 82c4755fb01f3ae821569af80d1b8670fe69fe56 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:26:32 -0600 Subject: wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 24 +++++++++++++----------- packages/ui/src/components/session-turn.tsx | 3 --- 2 files changed, 13 insertions(+), 14 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index caea614c9..66a2fcec7 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -500,17 +500,19 @@ export default function Page() { onMessageSelect={setActiveMessage} wide={wide()} /> - 1 ? "pr-6 pl-18" : "px-6"), - }} - /> + + 1 ? "pr-6 pl-18" : "px-6"), + }} + /> +
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e6654f480..281b2a03b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,9 +40,6 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) - - if (!message()) return null - const status = createMemo( () => data.store.session_status[props.sessionID] ?? { -- cgit v1.2.3 From 88c06751487291416eb473831cda70455f67412a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:41:37 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 21 +++------------------ packages/desktop/src/pages/layout.tsx | 3 +++ 2 files changed, 6 insertions(+), 18 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index a498593bd..fd5574990 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -93,13 +93,11 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number - slashFilter: string }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - slashFilter: "", }) const MAX_HISTORY = 100 @@ -237,25 +235,12 @@ export const PromptInput: Component = (props) => { onInput: slashOnInput, onKeyDown: slashOnKeyDown, } = useFilteredList({ - items: () => { - const filter = store.slashFilter.toLowerCase() - return slashCommands().filter( - (cmd) => - cmd.trigger.toLowerCase().includes(filter) || - cmd.title.toLowerCase().includes(filter) || - cmd.description?.toLowerCase().includes(filter) || - false, - ) - }, + items: slashCommands, key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) - // Update slash filter when store changes - createEffect(() => { - slashOnInput(store.slashFilter) - }) - createEffect( on( () => prompt.current(), @@ -337,7 +322,7 @@ export const PromptInput: Component = (props) => { onInput(atMatch[1]) setStore("popover", "file") } else if (slashMatch) { - setStore("slashFilter", slashMatch[1]) + slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { setStore("popover", null) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 965ade9f8..08d24dc6f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -299,6 +299,9 @@ export default function Layout(props: ParentProps) { if (match.found) draft.session.splice(match.index, 1) }), ) + if (session.id === params.id) { + navigate(`/${params.dir}/session`) + } } return (
Date: Mon, 15 Dec 2025 05:42:25 -0600 Subject: Revert "wip(desktop): session turn state consolidation" This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1. --- packages/ui/src/components/session-turn.tsx | 617 ++++++++++++++-------------- 1 file changed, 317 insertions(+), 300 deletions(-) (limited to 'packages') diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 281b2a03b..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -49,362 +49,379 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - - const assistantMessages = createMemo(() => { - return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message()!.id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") - resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - const [store, setStore] = createStore({ + const [state, setState] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - lastStatusChange: Date.now(), - statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setStore("scrollY", scrollRef.scrollTop) - if (store.autoScrolling) return + setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return - setStore("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setStore("autoScrolling", false) + setState("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setStore("userScrolled", false) + setState("userScrolled", false) } }) createResizeObserver( - () => store.stickyTitleRef, + () => state.stickyTitleRef, ({ height }) => { - const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => store.stickyTriggerRef, + () => state.stickyTriggerRef, ({ height }) => { - const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) }, ) - createEffect(() => { - lastPart() - scrollToBottom() - }) + return ( +
+
+
+ + {(message) => { + const assistantMessages = createMemo(() => { + return messages()?.filter( + (m) => m.role === "assistant" && m.parentID == message().id, + ) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message().id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo( + () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, + ) - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( + (m) => m.role === "assistant", + ) + resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) - const timeSinceLastChange = Date.now() - store.lastStatusChange + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - setStore("lastStatusChange", Date.now()) - if (store.statusTimeout) { - clearTimeout(store.statusTimeout) - setStore("statusTimeout", undefined) - } - } else { - if (store.statusTimeout) clearTimeout(store.statusTimeout) - setStore( - "statusTimeout", - setTimeout(() => { - setStore("status", rawStatus()) - setStore("lastStatusChange", Date.now()) - setStore("statusTimeout", undefined) - }, 2500 - timeSinceLastChange) as unknown as number, - ) - } - }) + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !store.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) + createEffect(() => { + lastPart() + scrollToBottom() + }) - return ( -
-
-
-
- {/* Title (sticky) */} -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message()!.summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( + const [store, setStore] = createStore({ + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + lastStatusChange = Date.now() + if (statusTimeout) { + clearTimeout(statusTimeout) + statusTimeout = undefined + } + } else { + if (statusTimeout) clearTimeout(statusTimeout) + statusTimeout = setTimeout(() => { + setStore("status", rawStatus()) + lastStatusChange = Date.now() + statusTimeout = undefined + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !state.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + + return ( +
+ {/* Title (sticky) */} +
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> +
+
+ + + + + +

{message().summary?.title}

+
+
+
+
+
+ {/* User Message */} +
+ +
+ {/* Trigger (sticky) */} +
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> +
- - {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} + · + {store.duration} + + +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )} + +
+ + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} +
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
- - - - {error()?.data?.message as string} - - -
+ ) + }} +
{props.children}
-- cgit v1.2.3 From c36f3b9dbe5547576545a77679b8898c205a0c30 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:50:10 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 4 +++- packages/ui/src/assets/audio/nope-01.aac | Bin 0 -> 6316 bytes packages/ui/src/assets/audio/nope-02.aac | Bin 0 -> 7431 bytes packages/ui/src/assets/audio/nope-03.aac | Bin 0 -> 6688 bytes packages/ui/src/assets/audio/nope-04.aac | Bin 0 -> 5573 bytes packages/ui/src/assets/audio/nope-05.aac | Bin 0 -> 6316 bytes 6 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/audio/nope-01.aac create mode 100644 packages/ui/src/assets/audio/nope-02.aac create mode 100644 packages/ui/src/assets/audio/nope-03.aac create mode 100644 packages/ui/src/assets/audio/nope-04.aac create mode 100644 packages/ui/src/assets/audio/nope-05.aac (limited to 'packages') diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 744e4fdf3..c18b12796 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,6 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/error-3.aac" type NotificationBase = { directory?: string @@ -29,6 +30,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi name: "Notification", init: () => { const idlePlayer = makeAudioPlayer(idleSound) + const errorPlayer = makeAudioPlayer(errorSound) const globalSDK = useGlobalSDK() const [store, setStore] = makePersisted( @@ -65,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi break } case "session.error": { + errorPlayer.play() const session = event.properties.sessionID ?? "global" - // errorPlayer.play() setStore("list", store.list.length, { ...base, type: "error", diff --git a/packages/ui/src/assets/audio/nope-01.aac b/packages/ui/src/assets/audio/nope-01.aac new file mode 100644 index 000000000..9fb614d08 Binary files /dev/null and b/packages/ui/src/assets/audio/nope-01.aac differ diff --git a/packages/ui/src/assets/audio/nope-02.aac b/packages/ui/src/assets/audio/nope-02.aac new file mode 100644 index 000000000..75603cc16 Binary files /dev/null and b/packages/ui/src/assets/audio/nope-02.aac differ diff --git a/packages/ui/src/assets/audio/nope-03.aac b/packages/ui/src/assets/audio/nope-03.aac new file mode 100644 index 000000000..1fe459a16 Binary files /dev/null and b/packages/ui/src/assets/audio/nope-03.aac differ diff --git a/packages/ui/src/assets/audio/nope-04.aac b/packages/ui/src/assets/audio/nope-04.aac new file mode 100644 index 000000000..b731a2a07 Binary files /dev/null and b/packages/ui/src/assets/audio/nope-04.aac differ diff --git a/packages/ui/src/assets/audio/nope-05.aac b/packages/ui/src/assets/audio/nope-05.aac new file mode 100644 index 000000000..4534191b6 Binary files /dev/null and b/packages/ui/src/assets/audio/nope-05.aac differ -- cgit v1.2.3 From c0d009d5f33c368f61ebe9a87460b1fbf5801d33 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:54:44 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) (limited to 'packages') diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index c18b12796..705551944 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,7 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" -import errorSound from "@opencode-ai/ui/audio/error-3.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" type NotificationBase = { directory?: string diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 08d24dc6f..df162c187 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project } from "@opencode-ai/sdk/v2/client" @@ -287,6 +288,11 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) const notifications = createMemo(() => notification.session.unseen(session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy", + ) async function archive(session: Session) { await globalSDK.client.session.update({ directory: session.directory, @@ -319,6 +325,9 @@ export default function Layout(props: ParentProps) {
+ + +
-- cgit v1.2.3 From 315836c0b7f7f852c99d8c9945fa15e63a3ea2bc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:06:05 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 6 +-- packages/desktop/src/pages/session.tsx | 64 ++++++++++-------------- 2 files changed, 29 insertions(+), 41 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index fd5574990..4dd8d878c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -591,8 +591,8 @@ export const PromptInput: Component = (props) => { {/* Popover for file mentions and slash commands */}
@@ -602,7 +602,7 @@ export const PromptInput: Component = (props) => { {(i) => (
-- cgit v1.2.3 From df2ebfac7d3dca6c2262258e6ee85a3c22cc53c3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:52:54 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 +++++++ packages/desktop/src/pages/layout.tsx | 6 +++ packages/desktop/src/pages/session.tsx | 69 ++++++++++++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 20 +++++++-- 4 files changed, 106 insertions(+), 4 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index af71c6a00..604f7c5d1 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + steps: { + expanded: false, + }, sessionTabs: {} as Record, }), { @@ -161,6 +164,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + steps: { + expanded: createMemo(() => store.steps?.expanded ?? false), + toggle() { + setStore("steps", "expanded", (x) => !x) + }, + expand() { + setStore("steps", "expanded", true) + }, + collapse() { + setStore("steps", "expanded", false) + }, + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index bb2302503..53078e01b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -156,6 +156,12 @@ export default function Layout(props: ParentProps) { }, ] : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, { id: "session.previous", title: "Previous session", diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 5c74f2d2e..d0c3bf7de 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" @@ -70,6 +71,25 @@ export default function Page() { setMessageStore("messageId", message?.id) } + function navigateMessageByOffset(offset: number) { + const messages = userMessages() + if (messages.length === 0) return + + const current = activeMessage() + const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : messages.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= messages.length) return + + setActiveMessage(messages[targetIndex]) + } + const last = createMemo( () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, ) @@ -118,7 +138,7 @@ export default function Page() { title: "New session", description: "Create a new session", category: "Session", - keybind: "mod+n", + keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, @@ -163,6 +183,49 @@ export default function Page() { keybind: "ctrl+shift+`", onSelect: () => terminal.new(), }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + onSelect: () => layout.steps.toggle(), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + slash: "model", + onSelect: () => dialog.replace(() => ), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + slash: "agent", + onSelect: () => local.agent.move(1), + }, ]) // Handle keyboard events that aren't commands @@ -492,6 +555,10 @@ export default function Page() { + expanded ? layout.steps.expand() : layout.steps.collapse() + } classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 54dd01091..807092d03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -24,6 +24,8 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + stepsExpanded?: boolean + onStepsExpandedChange?: (expanded: boolean) => void classes?: { root?: string content?: string @@ -222,10 +224,17 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: working(), + stepsExpanded: props.stepsExpanded ?? working(), duration: duration(), }) + // Sync with controlled prop + createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) + createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) @@ -262,6 +271,7 @@ export function SessionTurn( const isWorking = working() if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) } return isWorking }, working()) @@ -278,7 +288,7 @@ export function SessionTurn(
- + @@ -298,7 +308,11 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} + onClick={() => { + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} > -- cgit v1.2.3 From 5e37a902ce0ad209cedb0a85e997f1964064424a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:59:01 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 --------------- packages/desktop/src/pages/session.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 20 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 604f7c5d1..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,9 +46,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, - steps: { - expanded: false, - }, sessionTabs: {} as Record, }), { @@ -164,18 +161,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - steps: { - expanded: createMemo(() => store.steps?.expanded ?? false), - toggle() { - setStore("steps", "expanded", (x) => !x) - }, - expand() { - setStore("steps", "expanded", true) - }, - collapse() { - setStore("steps", "expanded", false) - }, - }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d0c3bf7de..d49779587 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -115,6 +115,7 @@ export default function Page() { clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, + stepsExpanded: false, }) let inputRef!: HTMLDivElement @@ -190,7 +191,8 @@ export default function Page() { category: "View", keybind: "mod+e", slash: "steps", - onSelect: () => layout.steps.toggle(), + disabled: !params.id, + onSelect: () => setStore("stepsExpanded", (x) => !x), }, { id: "message.previous", @@ -555,10 +557,8 @@ export default function Page() { - expanded ? layout.steps.expand() : layout.steps.collapse() - } + stepsExpanded={store.stepsExpanded} + onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", -- cgit v1.2.3 From ff6864a7ca3772e6f2702d585c6bb64a40bd6cce Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:05:50 -0600 Subject: feat(desktop): custom commands --- packages/desktop/src/components/prompt-input.tsx | 74 +++++++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 4 ++ 2 files changed, 69 insertions(+), 9 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 51c4e24d2..87f91104c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -61,6 +61,7 @@ interface SlashCommand { title: string description?: string keybind?: string + type: "builtin" | "custom" } export const PromptInput: Component = (props) => { @@ -208,8 +209,8 @@ export const PromptInput: Component = (props) => { }) // Get slash commands from registered commands (only those with explicit slash trigger) - const slashCommands = createMemo(() => - command.options + const slashCommands = createMemo(() => { + const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) .map((opt) => ({ id: opt.id, @@ -217,15 +218,46 @@ export const PromptInput: Component = (props) => { title: opt.title, description: opt.description, keybind: opt.keybind, - })), - ) + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + })) + + return [...custom, ...builtin] + }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - // Since slash commands only trigger from start, just clear the input + setStore("popover", null) + + if (cmd.type === "custom") { + // For custom commands, insert the command text so user can add arguments + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + // Set cursor at end + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + + // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -571,6 +603,23 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + // Check if this is a custom command + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) // Remove leading "/" + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + sdk.client.session.command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent: local.agent.current()!.name, + model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`, + }) + return + } + } + sdk.client.session.prompt({ sessionID: existing.id, agent: local.agent.current()!.name, @@ -641,9 +690,16 @@ export const PromptInput: Component = (props) => { {cmd.description}
- - {formatKeybind(cmd.keybind!)} - +
+ + + custom + + + + {formatKeybind(cmd.keybind!)} + +
)} diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..b90dde34f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -118,6 +121,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), -- cgit v1.2.3 From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:14:03 -0600 Subject: chore: cleanup --- packages/desktop/src/components/prompt-input.tsx | 21 ++++++--------------- packages/desktop/src/context/command.tsx | 17 ----------------- packages/desktop/src/pages/session.tsx | 19 +++---------------- packages/ui/src/components/session-turn.tsx | 1 - 4 files changed, 9 insertions(+), 49 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 87f91104c..9be09507a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -77,7 +77,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement - // Session-derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) @@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef?.addEventListener("paste", handlePaste) + editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef?.removeEventListener("paste", handlePaste) + editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { @@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) - // Get slash commands from registered commands (only those with explicit slash trigger) const slashCommands = createMemo(() => { const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) @@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - // For custom commands, insert the command text so user can add arguments const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - // Set cursor at end requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => { return } - // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part: ContentPart) => { + currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") + const rawText = currentPrompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - // Handle popover navigation if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) @@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => { } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - // Skip history navigation when modifier keys are pressed (used for other commands) if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return @@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part: ContentPart) => part.content).join("") + const text = currentPrompt.map((part) => part.content).join("") if (text.trim().length === 0) { if (working()) abort() return @@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => { const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( - (part: ContentPart) => part.type === "file", + (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { @@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - // Check if this is a custom command if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) // Remove leading "/" @@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => { return (
- {/* Popover for file mentions and slash commands */}
void } @@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return - // Check for command palette keybind (mod+shift+p) const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() @@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return } - // Check registered command keybinds for (const option of options()) { if (option.disabled) continue if (!option.keybind) continue diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d49779587..9e743e48f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -49,7 +49,6 @@ export default function Page() { const params = useParams() const navigate = useNavigate() - // Session-specific derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -132,7 +131,6 @@ export default function Page() { } }) - // Register commands for this page command.register(() => [ { id: "session.new", @@ -230,28 +228,17 @@ export default function Page() { }, ]) - // Handle keyboard events that aren't commands const handleKeyDown = (event: KeyboardEvent) => { - // Don't interfere with terminal // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } - - // Don't interfere with dialogs - if (dialog.stack.length > 0) { - return - } + if (document.activeElement?.dataset?.component === "terminal") return + if (dialog.stack.length > 0) return const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 807092d03..f905abbd1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,7 +228,6 @@ export function SessionTurn( duration: duration(), }) - // Sync with controlled prop createEffect(() => { if (props.stepsExpanded !== undefined) { setStore("stepsExpanded", props.stepsExpanded) -- cgit v1.2.3 From 5eaa8e1bf4862bfc64f114f7e9b31fc22e79be44 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:18:15 -0600 Subject: chore: cleanup --- packages/desktop/src/pages/session.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'packages') diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 9e743e48f..05a9e8a1d 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -233,6 +233,19 @@ export default function Page() { if (document.activeElement?.dataset?.component === "terminal") return if (dialog.stack.length > 0) return + if (event.key === "PageUp" || event.key === "PageDown") { + const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement + if (scrollContainer) { + event.preventDefault() + const scrollAmount = scrollContainer.clientHeight * 0.8 + scrollContainer.scrollBy({ + top: event.key === "PageUp" ? -scrollAmount : scrollAmount, + behavior: "instant", + }) + } + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") inputRef?.blur() -- cgit v1.2.3 From 44d6c5780d41616bf29a749020c9d7f98895407f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:25:24 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 47 ++++++++---------------- 1 file changed, 16 insertions(+), 31 deletions(-) (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 9be09507a..37d05c311 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -133,31 +133,20 @@ export const PromptInput: Component = (props) => { }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } @@ -505,17 +494,13 @@ export const PromptInput: Component = (props) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return -- cgit v1.2.3 From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:34:00 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 191 +++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 40 ++++- packages/desktop/src/context/local.tsx | 2 +- packages/desktop/src/context/prompt.tsx | 14 +- packages/desktop/src/pages/layout.tsx | 211 +++++++++++++++-------- packages/desktop/src/pages/session.tsx | 148 +++++++++++----- packages/desktop/src/utils/prompt.ts | 47 +++++ packages/ui/src/components/message-part.css | 76 +++++++- packages/ui/src/components/message-part.tsx | 96 ++++++++++- packages/ui/src/components/session-turn.tsx | 10 +- 10 files changed, 676 insertions(+), 159 deletions(-) create mode 100644 packages/desktop/src/utils/prompt.ts (limited to 'packages') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => { + +
+
+ + Drop images or PDFs here +
+
+
+ 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} + + +
+ {attachment.filename} +
+
+ )} + +
+
{ @@ -706,7 +845,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => { > { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {