mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-17 14:24:22 +00:00
Compare commits
43 Commits
chore-clea
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6817e448 | ||
|
|
946c2cd1f7 | ||
|
|
caa6eb4725 | ||
|
|
277c68d8e5 | ||
|
|
10985671ad | ||
|
|
3dfbb70593 | ||
|
|
07947bab7d | ||
|
|
4eed55973f | ||
|
|
6e984378d7 | ||
|
|
4fd3141ab5 | ||
|
|
8d0a303af4 | ||
|
|
0186a85063 | ||
|
|
ed4e4843c2 | ||
|
|
a93a1b93e1 | ||
|
|
ace63b3ddb | ||
|
|
d338bd528c | ||
|
|
ea2d089db0 | ||
|
|
4226097228 | ||
|
|
e31f00ad22 | ||
|
|
e35a4131d0 | ||
|
|
0e669b6016 | ||
|
|
9163611989 | ||
|
|
d93cefd47a | ||
|
|
a580fb47d2 | ||
|
|
9d3c81a683 | ||
|
|
86e545a23e | ||
|
|
b0afdf6ea4 | ||
|
|
d8c25bfeb4 | ||
|
|
160ba295a8 | ||
|
|
16332a8583 | ||
|
|
a90e8de050 | ||
|
|
eabf770053 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
bb4d978684 | ||
|
|
afec40e8da |
1
.github/actions/setup-bun/action.yml
vendored
1
.github/actions/setup-bun/action.yml
vendored
@@ -4,6 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Mount Bun Cache
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
||||
|
||||
24
.github/workflows/publish.yml
vendored
24
.github/workflows/publish.yml
vendored
@@ -137,7 +137,7 @@ jobs:
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
path: ~/apt-cache
|
||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||
@@ -145,8 +145,10 @@ jobs:
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: |
|
||||
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo chmod -R a+rw ~/apt-cache
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -169,13 +171,23 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Resolve tauri portable SHA
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||
|
||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||
- name: Install tauri-cli from portable appimage branch
|
||||
uses: taiki-e/cache-cargo-install-action@v3
|
||||
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
|
||||
with:
|
||||
tool: tauri-cli
|
||||
git: https://github.com/tauri-apps/tauri
|
||||
# branch: feat/truly-portable-appimage
|
||||
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||
|
||||
- name: Show tauri-cli version
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: cargo tauri --version
|
||||
|
||||
- name: Build and upload artifacts
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
|
||||
@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
|
||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||
opencode session [command]
|
||||
opencode session list
|
||||
opencode session delete <sessionID>
|
||||
opencode stats
|
||||
opencode uninstall
|
||||
opencode upgrade
|
||||
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||
OPENCODE_EXPERIMENTAL_OXFMT
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||
OPENCODE_ENABLE_QUESTION_TOOL
|
||||
OPENCODE_FAKE_VCS
|
||||
OPENCODE_GIT_BASH_PATH
|
||||
OPENCODE_MODEL
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/claude-haiku-4-5
|
||||
model: opencode/minimax-m2.5
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
|
||||
|
||||
Use your github-triage tool to triage issues.
|
||||
|
||||
This file is the source of truth for ownership/routing rules.
|
||||
|
||||
## Labels
|
||||
|
||||
### windows
|
||||
@@ -43,12 +45,30 @@ Desktop app issues:
|
||||
|
||||
**Only** add if the issue explicitly mentions nix.
|
||||
|
||||
If the issue does not mention nix, do not add nix.
|
||||
|
||||
If the issue mentions nix, assign to `rekram1-node`.
|
||||
|
||||
#### zen
|
||||
|
||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||
|
||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||
|
||||
#### core
|
||||
|
||||
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||
|
||||
Examples:
|
||||
|
||||
- LSP server behavior
|
||||
- Harness behavior (agent + tools)
|
||||
- Feature requests for server behavior
|
||||
- Agent context construction
|
||||
- API endpoints
|
||||
- Provider integration issues
|
||||
- New, broken, or poor-quality models
|
||||
|
||||
#### docs
|
||||
|
||||
Add if the issue requests better documentation or docs updates.
|
||||
@@ -66,13 +86,47 @@ TUI issues potentially caused by our underlying TUI library:
|
||||
|
||||
When assigning to people here are the following rules:
|
||||
|
||||
adamdotdev:
|
||||
ONLY assign adam if the issue will have the "desktop" label.
|
||||
Desktop / Web:
|
||||
Use for desktop-labeled issues only.
|
||||
|
||||
fwang:
|
||||
ONLY assign fwang if the issue will have the "zen" label.
|
||||
- adamdotdevin
|
||||
- iamdavidhill
|
||||
- Brendonovich
|
||||
- nexxeln
|
||||
|
||||
jayair:
|
||||
ONLY assign jayair if the issue will have the "docs" label.
|
||||
Zen:
|
||||
ONLY assign if the issue will have the "zen" label.
|
||||
|
||||
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
|
||||
- fwang
|
||||
- MrMushrooooom
|
||||
|
||||
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||
|
||||
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||
|
||||
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||
|
||||
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||
|
||||
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||
|
||||
Docs:
|
||||
|
||||
- R44VC0RP
|
||||
|
||||
Windows:
|
||||
|
||||
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||
|
||||
Determinism rules:
|
||||
|
||||
- If title + body does not contain "zen", do not add the "zen" label
|
||||
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||
|
||||
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
// import { Octokit } from "@octokit/rest"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||
docs: ["R44VC0RP"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||
|
||||
function pick<T>(items: readonly T[]) {
|
||||
return items[Math.floor(Math.random() * items.length)]!
|
||||
}
|
||||
|
||||
function getIssueNumber(): number {
|
||||
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
||||
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
||||
@@ -29,60 +43,79 @@ export default tool({
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
.describe("The username of the assignee")
|
||||
.default("rekram1-node"),
|
||||
labels: tool.schema
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||
.describe("The labels(s) to add to the issue")
|
||||
.default([]),
|
||||
},
|
||||
async execute(args) {
|
||||
const issue = getIssueNumber()
|
||||
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
|
||||
const owner = "anomalyco"
|
||||
const repo = "opencode"
|
||||
|
||||
const results: string[] = []
|
||||
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||
const web = labels.includes("web")
|
||||
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||
const nix = /\bnix(os)?\b/.test(text)
|
||||
|
||||
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
|
||||
throw new Error("Only desktop issues should be assigned to adamdotdevin")
|
||||
if (labels.includes("nix") && !nix) {
|
||||
labels = labels.filter((x) => x !== "nix")
|
||||
results.push("Dropped label: nix (issue does not mention nix)")
|
||||
}
|
||||
|
||||
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
|
||||
throw new Error("Only zen issues should be assigned to fwang")
|
||||
const assignee = nix
|
||||
? "rekram1-node"
|
||||
: web
|
||||
? pick(TEAM.desktop)
|
||||
: args.assignee === "jlongster"
|
||||
? "thdxr"
|
||||
: args.assignee
|
||||
|
||||
if (args.assignee === "jlongster" && assignee === "thdxr") {
|
||||
results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)")
|
||||
}
|
||||
|
||||
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
|
||||
if (labels.includes("zen") && !zen) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
}
|
||||
|
||||
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||
}
|
||||
|
||||
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||
}
|
||||
|
||||
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||
throw new Error("Only windows issues should be assigned to Hona")
|
||||
}
|
||||
|
||||
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||
}
|
||||
|
||||
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||
throw new Error("Only opentui issues should be assigned to kommander")
|
||||
}
|
||||
|
||||
// await octokit.rest.issues.addAssignees({
|
||||
// owner,
|
||||
// repo,
|
||||
// issue_number: issue,
|
||||
// assignees: [args.assignee],
|
||||
// })
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignees: [args.assignee] }),
|
||||
body: JSON.stringify({ assignees: [assignee] }),
|
||||
})
|
||||
results.push(`Assigned @${args.assignee} to issue #${issue}`)
|
||||
|
||||
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
|
||||
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||
|
||||
if (labels.length > 0) {
|
||||
// await octokit.rest.issues.addLabels({
|
||||
// owner,
|
||||
// repo,
|
||||
// issue_number: issue,
|
||||
// labels,
|
||||
// })
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels }),
|
||||
})
|
||||
results.push(`Added labels: ${args.labels.join(", ")}`)
|
||||
results.push(`Added labels: ${labels.join(", ")}`)
|
||||
}
|
||||
|
||||
return results.join("\n")
|
||||
|
||||
@@ -1,88 +1,6 @@
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
You can assign the following users:
|
||||
- thdxr
|
||||
- adamdotdevin
|
||||
- fwang
|
||||
- jayair
|
||||
- kommander
|
||||
- rekram1-node
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
|
||||
You can use the following labels:
|
||||
- nix
|
||||
- opentui
|
||||
- perf
|
||||
- web
|
||||
- zen
|
||||
- docs
|
||||
|
||||
Always try to assign an issue, if in doubt, assign rekram1-node to it.
|
||||
|
||||
## Breakdown of responsibilities:
|
||||
|
||||
### thdxr
|
||||
|
||||
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
|
||||
|
||||
This relates to OpenCode server primarily but has overlap with just about anything
|
||||
|
||||
### adamdotdevin
|
||||
|
||||
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
|
||||
|
||||
|
||||
### fwang
|
||||
|
||||
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
|
||||
|
||||
### jayair
|
||||
|
||||
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
|
||||
|
||||
### kommander
|
||||
|
||||
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
|
||||
- random characters on screen
|
||||
- keybinds not working on different terminals
|
||||
- general terminal stuff
|
||||
Then assign the issue to Him.
|
||||
|
||||
### rekram1-node
|
||||
|
||||
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
|
||||
|
||||
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
|
||||
If no one else makes sense to assign, assign rekram1-node to it.
|
||||
|
||||
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
|
||||
|
||||
## Breakdown of Labels:
|
||||
|
||||
### nix
|
||||
|
||||
Any issue that mentions nix, or nixos should have a nix label
|
||||
|
||||
### opentui
|
||||
|
||||
Anything relating to the TUI itself should have an opentui label
|
||||
|
||||
### perf
|
||||
|
||||
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
|
||||
|
||||
### desktop
|
||||
|
||||
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
|
||||
|
||||
### zen
|
||||
|
||||
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
|
||||
|
||||
### docs
|
||||
|
||||
Anything related to the documentation should have a docs label
|
||||
|
||||
### windows
|
||||
|
||||
Use for any issue that involves the windows OS
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
30
bun.lock
30
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -244,7 +244,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -369,7 +369,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -389,7 +389,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -400,7 +400,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -413,7 +413,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -466,7 +466,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -145,6 +145,16 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS18"),
|
||||
new sst.Secret("ZEN_MODELS19"),
|
||||
new sst.Secret("ZEN_MODELS20"),
|
||||
new sst.Secret("ZEN_MODELS21"),
|
||||
new sst.Secret("ZEN_MODELS22"),
|
||||
new sst.Secret("ZEN_MODELS23"),
|
||||
new sst.Secret("ZEN_MODELS24"),
|
||||
new sst.Secret("ZEN_MODELS25"),
|
||||
new sst.Secret("ZEN_MODELS26"),
|
||||
new sst.Secret("ZEN_MODELS27"),
|
||||
new sst.Secret("ZEN_MODELS28"),
|
||||
new sst.Secret("ZEN_MODELS29"),
|
||||
new sst.Secret("ZEN_MODELS30"),
|
||||
]
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||
|
||||
@@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
const key = await target.getAttribute("data-key")
|
||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||
|
||||
const name = (await target.locator("span").first().innerText()).trim()
|
||||
const model = key.split(":").slice(1).join(":")
|
||||
|
||||
await input.fill(model)
|
||||
@@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
|
||||
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/model")
|
||||
await expect(command).toBeVisible()
|
||||
await command.hover()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const dialogAgain = page.getByRole("dialog")
|
||||
await expect(dialogAgain).toBeVisible()
|
||||
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
gutter={4}
|
||||
>
|
||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
|
||||
@@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -94,7 +93,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const local = useLocal()
|
||||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
@@ -105,7 +103,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef!: HTMLInputElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
@@ -223,14 +221,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
})
|
||||
const placeholder = createMemo(() =>
|
||||
promptPlaceholder({
|
||||
mode: store.mode,
|
||||
commentCount: commentCount(),
|
||||
example: language.t(EXAMPLES[store.placeholder]),
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
})
|
||||
|
||||
const contextItems = createMemo(() => {
|
||||
const items = prompt.context.items()
|
||||
if (store.mode !== "shell") return items
|
||||
return items.filter((item) => !item.comment?.trim())
|
||||
})
|
||||
|
||||
const hasUserPrompt = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return false
|
||||
const messages = sync.data.message[sessionID]
|
||||
if (!messages) return false
|
||||
return messages.some((m) => m.role === "user")
|
||||
})
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
const [history, setHistory] = persisted(
|
||||
@@ -250,6 +259,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
const suggest = createMemo(() => !hasUserPrompt())
|
||||
|
||||
const placeholder = createMemo(() =>
|
||||
promptPlaceholder({
|
||||
mode: store.mode,
|
||||
commentCount: commentCount(),
|
||||
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
|
||||
suggest: suggest(),
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
|
||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||
const length = position === "start" ? 0 : promptLength(p)
|
||||
setStore("applyingHistory", true)
|
||||
@@ -280,6 +301,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||
|
||||
const pick = () => fileInputRef?.click()
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
setStore("popover", null)
|
||||
requestAnimationFrame(() => editorRef?.focus())
|
||||
}
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+u",
|
||||
disabled: store.mode !== "normal",
|
||||
onSelect: pick,
|
||||
},
|
||||
])
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
|
||||
@@ -325,6 +366,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
createEffect(() => {
|
||||
params.id
|
||||
if (params.id) return
|
||||
if (!suggest()) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||
}, 6500)
|
||||
@@ -815,6 +857,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
event.preventDefault()
|
||||
if (store.mode !== "normal") return
|
||||
pick()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.isCollapsed) {
|
||||
@@ -842,13 +891,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (store.mode === "shell") {
|
||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||
if (event.key === "Escape") {
|
||||
setStore("mode", "normal")
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (store.popover) {
|
||||
closePopover()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (store.mode === "shell") {
|
||||
setStore("mode", "normal")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (working()) {
|
||||
abort()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (escBlur()) {
|
||||
editorRef.blur()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (store.mode === "shell") {
|
||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
||||
setStore("mode", "normal")
|
||||
event.preventDefault()
|
||||
@@ -927,17 +1002,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event)
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (store.popover) {
|
||||
closePopover()
|
||||
} else if (working()) {
|
||||
abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||
<PromptPopover
|
||||
popover={store.popover}
|
||||
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
|
||||
@@ -957,8 +1027,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
|
||||
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
@@ -968,7 +1038,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={prompt.context.items()}
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
@@ -988,7 +1058,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onRemove={removeImageAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (
|
||||
target.closest(
|
||||
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
@@ -1009,141 +1094,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
<Icon name="console" size="small" class="text-icon-primary" />
|
||||
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
|
||||
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
||||
valueClass="truncate"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
class="px-2 min-w-0 max-w-[240px]"
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
triggerAs={Button}
|
||||
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Button
|
||||
data-action="model-variant-cycle"
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
{local.model.variant.current() ?? language.t("common.default")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={permission.permissionsEnabled() && params.id}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
aria-label={
|
||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
}
|
||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -1155,54 +1121,272 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-1 mr-1">
|
||||
<SessionContextUsage />
|
||||
<Show when={store.mode === "normal"}>
|
||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-6 px-1"
|
||||
onClick={() => fileInputRef.click()}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="photo" class="size-4.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!prompt.dirty() && !working()}
|
||||
value={
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
type="submit"
|
||||
disabled={!prompt.dirty() && !working() && commentCount() === 0}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-6 w-4.5"
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!prompt.dirty() && !working()}
|
||||
value={
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
aria-label={
|
||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
}
|
||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</form>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
||||
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Show when={store.mode === "shell"}>
|
||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={store.mode === "normal"}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px" }}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
data-component="prompt-mode-toggle"
|
||||
class="relative h-6 w-[68px] rounded-[4px] bg-surface-inset-base border border-[0.5px] border-border-weak-base p-0 flex items-center gap-1 overflow-visible"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 w-[calc((100%-4px)/2)] rounded-[4px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-xs-border)] transition-transform duration-200 ease-out will-change-transform"
|
||||
style={{
|
||||
transform: store.mode === "shell" ? "translateX(0px)" : "translateX(calc(100% + 4px))",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex-1 h-full p-0.5 flex items-center justify-center"
|
||||
aria-pressed={store.mode === "shell"}
|
||||
onClick={() => setMode("shell")}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center rounded-[2px] transition-colors hover:bg-surface-inset-base"
|
||||
classList={{ "hover:bg-transparent": store.mode === "shell" }}
|
||||
>
|
||||
<Icon
|
||||
name="console"
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-strong-base": store.mode === "shell",
|
||||
"text-icon-weak": store.mode !== "shell",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex-1 h-full p-0.5 flex items-center justify-center"
|
||||
aria-pressed={store.mode === "normal"}
|
||||
onClick={() => setMode("normal")}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center rounded-[2px] transition-colors hover:bg-surface-inset-base"
|
||||
classList={{ "hover:bg-transparent": store.mode === "normal" }}
|
||||
>
|
||||
<Icon
|
||||
name="prompt"
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-interactive-base": store.mode === "normal",
|
||||
"text-icon-weak": store.mode !== "normal",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
selected,
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||
"bg-background-stronger": !selected,
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
|
||||
@@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
|
||||
mode: "shell",
|
||||
commentCount: 0,
|
||||
example: "example",
|
||||
suggest: true,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.shell")
|
||||
})
|
||||
|
||||
test("returns summarize placeholders for comment context", () => {
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
|
||||
"prompt.placeholder.summarizeComment",
|
||||
)
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
|
||||
"prompt.placeholder.summarizeComments",
|
||||
)
|
||||
})
|
||||
|
||||
test("returns default placeholder with example", () => {
|
||||
test("returns default placeholder with example when suggestions enabled", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
suggest: true,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||
})
|
||||
|
||||
test("returns simple placeholder when suggestions disabled", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
suggest: false,
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.simple")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
|
||||
mode: "normal" | "shell"
|
||||
commentCount: number
|
||||
example: string
|
||||
suggest: boolean
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
}
|
||||
|
||||
@@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
ref={(el) => {
|
||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||
}}
|
||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
pending.delete(sessionID)
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
@@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, type Component } from "solid-js"
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -12,25 +12,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
const language = useLanguage()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
const total = createMemo(() => questions().length)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
customOn: [] as boolean[],
|
||||
editing: false,
|
||||
sending: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return `${n} of ${total()} questions`
|
||||
})
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
|
||||
setStore("custom", store.tab, value)
|
||||
if (!selected) return
|
||||
|
||||
if (multi()) {
|
||||
setStore("answers", store.tab, (current = []) => {
|
||||
const removed = prev ? current.filter((item) => item.trim() !== prev) : current
|
||||
if (!next) return removed
|
||||
if (removed.some((item) => item.trim() === next)) return removed
|
||||
return [...removed, next]
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setStore("answers", store.tab, next ? [next] : [])
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
if (!root) return
|
||||
|
||||
const scroller = document.querySelector(".session-scroller")
|
||||
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
||||
const top =
|
||||
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
||||
if (!top) {
|
||||
root.style.removeProperty("--question-prompt-max-height")
|
||||
return
|
||||
}
|
||||
|
||||
const dock = root.closest('[data-component="session-prompt-dock"]')
|
||||
if (!(dock instanceof HTMLElement)) return
|
||||
|
||||
const dockBottom = dock.getBoundingClientRect().bottom
|
||||
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
|
||||
const gap = 8
|
||||
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
|
||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf: number | undefined
|
||||
const update = () => {
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = undefined
|
||||
measure()
|
||||
})
|
||||
}
|
||||
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
|
||||
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||
const scroller = document.querySelector(".session-scroller")
|
||||
const observer = new ResizeObserver(update)
|
||||
if (dock instanceof HTMLElement) observer.observe(dock)
|
||||
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", update)
|
||||
observer.disconnect()
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
})
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
@@ -64,23 +137,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
}
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
setStore("answers", store.tab, [answer])
|
||||
|
||||
if (custom) {
|
||||
setStore("custom", store.tab, answer)
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
void reply([[answer]])
|
||||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
if (custom) setStore("custom", store.tab, answer)
|
||||
if (!custom) setStore("customOn", store.tab, false)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const toggle = (answer: string) => {
|
||||
@@ -90,16 +153,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
})
|
||||
}
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
setStore("tab", index)
|
||||
const customToggle = () => {
|
||||
if (store.sending) return
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
return
|
||||
}
|
||||
|
||||
const next = !on()
|
||||
setStore("customOn", store.tab, next)
|
||||
if (next) {
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
return
|
||||
}
|
||||
|
||||
const value = input().trim()
|
||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (store.sending) return
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (store.sending) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
setStore("editing", true)
|
||||
customOpen()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,67 +200,67 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
const handleCustomSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
const commitCustom = () => {
|
||||
setStore("editing", false)
|
||||
customUpdate(input())
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (store.sending) return
|
||||
if (store.editing) commitCustom()
|
||||
|
||||
const value = input().trim()
|
||||
if (!value) {
|
||||
setStore("editing", false)
|
||||
if (store.tab >= total() - 1) {
|
||||
submit()
|
||||
return
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
setStore("answers", store.tab, (current = []) => {
|
||||
if (current.includes(value)) return current
|
||||
return [...current, value]
|
||||
})
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
pick(value, true)
|
||||
const back = () => {
|
||||
if (store.sending) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (store.sending) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="question-prompt">
|
||||
<Show when={!single()}>
|
||||
<div data-slot="question-tabs">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const active = () => index() === store.tab
|
||||
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<div data-component="question-prompt" ref={(el) => (root = el)}>
|
||||
<div data-slot="question-body">
|
||||
<div data-slot="question-header">
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
data-slot="question-tab"
|
||||
data-active={active()}
|
||||
data-answered={answered()}
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectTab(index())}
|
||||
>
|
||||
{q.header}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
data-slot="question-tab"
|
||||
data-active={confirm()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectTab(questions().length)}
|
||||
>
|
||||
{language.t("ui.common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">
|
||||
{question()?.question}
|
||||
{multi() ? " " + language.t("ui.question.multiHint") : ""}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
@@ -181,106 +269,156 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<Show when={picked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={customPicked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(options().length)}
|
||||
>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<Show when={!store.editing && input()}>
|
||||
<span data-slot="option-description">{input()}</span>
|
||||
</Show>
|
||||
<Show when={customPicked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={store.editing}>
|
||||
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
||||
<input
|
||||
ref={(el) => setTimeout(() => el.focus(), 0)}
|
||||
type="text"
|
||||
data-slot="custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onInput={(e) => {
|
||||
setStore("custom", store.tab, e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
||||
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={store.sending}
|
||||
onClick={() => setStore("editing", false)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
{language.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">
|
||||
{input() || language.t("ui.question.custom.placeholder")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={confirm()}>
|
||||
<div data-slot="question-review">
|
||||
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<div data-slot="review-item">
|
||||
<span data-slot="review-label">{q.question}</span>
|
||||
<span data-slot="review-value" data-answered={answered()}>
|
||||
{answered() ? value() : language.t("ui.question.review.notAnswered")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div data-slot="question-actions">
|
||||
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
|
||||
<div data-slot="question-footer">
|
||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<Show when={!single()}>
|
||||
<Show when={confirm()}>
|
||||
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
|
||||
{language.t("ui.common.submit")}
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!confirm() && multi()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => selectTab(store.tab + 1)}
|
||||
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
|
||||
>
|
||||
{language.t("ui.common.next")}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
placement?: TooltipProps["placement"]
|
||||
}
|
||||
|
||||
function openSessionContext(args: {
|
||||
@@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabs().active() === "context") {
|
||||
tabs().close("context")
|
||||
return
|
||||
}
|
||||
openSessionContext({
|
||||
view: view(),
|
||||
layout,
|
||||
@@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
return (
|
||||
<Show when={params.id}>
|
||||
<Tooltip value={tooltipValue()} placement="top">
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={true}>
|
||||
|
||||
208
packages/app/src/components/session-todo-dock.tsx
Normal file
208
packages/app/src/components/session-todo-dock.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="3"
|
||||
style={{
|
||||
animation: "var(--animate-pulse-scale)",
|
||||
"transform-origin": "center",
|
||||
"transform-box": "fill-box",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const total = props.todos.length
|
||||
if (total === 0) return ""
|
||||
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
||||
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
||||
})
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
props.todos.find((todo) => todo.status === "in_progress") ??
|
||||
props.todos.find((todo) => todo.status === "pending") ??
|
||||
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
|
||||
props.todos[0],
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
||||
"h-[78px]": store.collapsed,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
||||
<Show when={store.collapsed}>
|
||||
<div class="ml-1 flex-1 min-w-0">
|
||||
<Show when={preview()}>
|
||||
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": !store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div hidden={store.collapsed}>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
const [scrolling, setScrolling] = createSignal(false)
|
||||
let scrollRef!: HTMLDivElement
|
||||
let timer: number | undefined
|
||||
|
||||
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
|
||||
|
||||
const ensure = () => {
|
||||
if (!props.open) return
|
||||
if (scrolling()) return
|
||||
if (!scrollRef || scrollRef.offsetParent === null) return
|
||||
|
||||
const el = scrollRef.querySelector("[data-in-progress]")
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
|
||||
const topFade = 16
|
||||
const bottomFade = 44
|
||||
const container = scrollRef.getBoundingClientRect()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = rect.top - container.top + scrollRef.scrollTop
|
||||
const bottom = rect.bottom - container.top + scrollRef.scrollTop
|
||||
const viewTop = scrollRef.scrollTop + topFade
|
||||
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
|
||||
|
||||
if (top < viewTop) {
|
||||
scrollRef.scrollTop = Math.max(0, top - topFade)
|
||||
} else if (bottom > viewBottom) {
|
||||
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
|
||||
}
|
||||
|
||||
setStuck(scrollRef.scrollTop > 0)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on([() => props.open, inProgress], () => {
|
||||
if (!props.open || inProgress() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||
ref={scrollRef}
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStuck(e.currentTarget.scrollTop > 0)
|
||||
setScrolling(true)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setScrolling(false)
|
||||
if (inProgress() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<For each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo.status === "completed"}
|
||||
indeterminate={todo.status === "in_progress"}
|
||||
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
||||
icon={dot(todo.status)}
|
||||
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
classList={{
|
||||
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
||||
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
||||
}}
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
"text-decoration":
|
||||
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
opacity: stuck() ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -372,7 +372,7 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
onClick={() => openDir(current().id)}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
@@ -552,14 +552,14 @@ export function SessionHeader() {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-3 ml-2 shrink-0">
|
||||
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle size-6 p-0"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().terminal.toggle()}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
@@ -568,7 +568,7 @@ export function SessionHeader() {
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
@@ -578,18 +578,18 @@ export function SessionHeader() {
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden lg:block shrink-0">
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
@@ -598,7 +598,7 @@ export function SessionHeader() {
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
@@ -608,38 +608,57 @@ export function SessionHeader() {
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden lg:block shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
<div class="hidden md:block shrink-0">
|
||||
<div
|
||||
aria-hidden={!view().reviewPanel.opened()}
|
||||
class="overflow-hidden transition-[width,margin-left] duration-200 ease-out motion-reduce:transition-none"
|
||||
classList={{
|
||||
"w-8 ml-0": view().reviewPanel.opened(),
|
||||
"w-0 -ml-1": !view().reviewPanel.opened(),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/file-tree-toggle size-6 p-0"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
<div
|
||||
class="transition-[opacity,transform] duration-200 ease-out origin-center motion-reduce:transition-none"
|
||||
classList={{
|
||||
"opacity-100 scale-100": view().reviewPanel.opened(),
|
||||
"opacity-0 scale-90": !view().reviewPanel.opened(),
|
||||
}}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name="bullet-list"
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
disabled={!view().reviewPanel.opened()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
tabIndex={view().reviewPanel.opened() ? undefined : -1}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
const ROOT_CLASS =
|
||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
|
||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
|
||||
@@ -196,19 +196,21 @@ export function StatusPopover() {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -43,6 +43,7 @@ export function Titlebar() {
|
||||
const theme = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -171,9 +172,10 @@ export function Titlebar() {
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="size-8 rounded-md"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -182,13 +184,14 @@ export function Titlebar() {
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="size-8 rounded-md"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||
placement="bottom"
|
||||
@@ -197,7 +200,7 @@ export function Titlebar() {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/sidebar-toggle size-6 p-0"
|
||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={layout.sidebar.toggle}
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
@@ -205,39 +208,60 @@ export function Titlebar() {
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"}
|
||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
|
||||
class="group-hover/sidebar-toggle:hidden"
|
||||
/>
|
||||
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"}
|
||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
|
||||
class="hidden group-active/sidebar-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="arrow-left"
|
||||
class="size-6 p-0"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="arrow-right"
|
||||
class="size-6 p-0"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="new-session"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
@@ -254,7 +278,7 @@ export function Titlebar() {
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
|
||||
@@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
|
||||
@@ -2,9 +2,14 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import z from "zod"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
const abortError = z.object({
|
||||
name: z.literal("AbortError"),
|
||||
})
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
@@ -93,12 +98,35 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
|
||||
let streamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||
|
||||
let attempt: AbortController | undefined
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
const resetHeartbeat = () => {
|
||||
if (heartbeat) clearTimeout(heartbeat)
|
||||
heartbeat = setTimeout(() => {
|
||||
attempt?.abort()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}
|
||||
const clearHeartbeat = () => {
|
||||
if (!heartbeat) return
|
||||
clearTimeout(heartbeat)
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
while (!abort.signal.aborted) {
|
||||
attempt = new AbortController()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (aborted(error)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
@@ -109,7 +137,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload
|
||||
@@ -130,7 +160,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!streamErrorLogged) {
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: server.url,
|
||||
@@ -138,6 +168,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted) return
|
||||
@@ -145,7 +179,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
}
|
||||
})().finally(flush)
|
||||
|
||||
const onVisibility = () => {
|
||||
if (typeof document === "undefined") return
|
||||
if (document.visibilityState !== "visible") return
|
||||
attempt?.abort()
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility)
|
||||
}
|
||||
abort.abort()
|
||||
flush()
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Project,
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
type Todo,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -41,6 +42,9 @@ type GlobalStore = {
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
@@ -87,12 +91,27 @@ function createGlobalSync() {
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: projectCache.value,
|
||||
session_todo: {},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
|
||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||
if (!sessionID) return
|
||||
if (!todos) {
|
||||
setGlobalStore(
|
||||
"session_todo",
|
||||
produce((draft) => {
|
||||
delete draft[sessionID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
|
||||
}
|
||||
|
||||
const updateStats = (activeDirectoryStores: number) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
setDevStats({
|
||||
@@ -270,6 +289,11 @@ function createGlobalSync() {
|
||||
setGlobalStore("project", next)
|
||||
},
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -283,6 +307,7 @@ function createGlobalSync() {
|
||||
store,
|
||||
setStore,
|
||||
push: queue.push,
|
||||
setSessionTodo,
|
||||
vcsCache: children.vcsCache.get(directory),
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
@@ -353,6 +378,9 @@ function createGlobalSync() {
|
||||
bootstrap,
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
type QuestionRequest,
|
||||
type Todo,
|
||||
createOpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { batch } from "solid-js"
|
||||
@@ -20,6 +21,9 @@ type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
|
||||
@@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => {
|
||||
|
||||
expect(refreshCount).toBe(1)
|
||||
})
|
||||
|
||||
test("handles server.connected by triggering refresh", () => {
|
||||
let refreshCount = 0
|
||||
applyGlobalEvent({
|
||||
event: { type: "server.connected" },
|
||||
project: [],
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject() {},
|
||||
})
|
||||
|
||||
expect(refreshCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed") {
|
||||
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
|
||||
function cleanupSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
const hasAny =
|
||||
store.message[sessionID] !== undefined ||
|
||||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
|
||||
store.permission[sessionID] !== undefined ||
|
||||
store.question[sessionID] !== undefined ||
|
||||
store.session_status[sessionID] !== undefined
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
if (!hasAny) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
|
||||
directory: string
|
||||
loadLsp: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
}) {
|
||||
const event = input.event
|
||||
switch (event.type) {
|
||||
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
|
||||
@@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
if (store.todo[sessionID] !== undefined) return
|
||||
const existing = store.todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (globalSync.data.session_todo[sessionID] === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ export const dict = {
|
||||
"common.attachment": "مرفق",
|
||||
"prompt.placeholder.shell": "أدخل أمر shell...",
|
||||
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
|
||||
"prompt.placeholder.simple": "اسأل أي شيء...",
|
||||
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
||||
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -447,6 +448,9 @@ export const dict = {
|
||||
"session.messages.loading": "جارٍ تحميل الرسائل...",
|
||||
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
|
||||
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||
|
||||
@@ -206,6 +206,7 @@ export const dict = {
|
||||
"common.attachment": "anexo",
|
||||
"prompt.placeholder.shell": "Digite comando do shell...",
|
||||
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
|
||||
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
||||
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -450,6 +451,9 @@ export const dict = {
|
||||
"session.messages.loading": "Carregando mensagens...",
|
||||
"session.messages.jumpToLatest": "Ir para a mais recente",
|
||||
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
"session.new.worktree.create": "Criar novo worktree",
|
||||
|
||||
@@ -224,6 +224,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Unesi shell naredbu...",
|
||||
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Pitaj bilo šta...",
|
||||
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
||||
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -505,6 +506,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Idi na najnovije",
|
||||
|
||||
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
|
||||
"session.todo.title": "Zadaci",
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||
|
||||
@@ -222,6 +222,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Indtast shell-kommando...",
|
||||
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Spørg om hvad som helst...",
|
||||
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
|
||||
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -500,6 +501,9 @@ export const dict = {
|
||||
|
||||
"session.messages.jumpToLatest": "Gå til seneste",
|
||||
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
|
||||
"session.todo.title": "Opgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||
|
||||
@@ -211,6 +211,7 @@ export const dict = {
|
||||
"common.attachment": "Anhang",
|
||||
"prompt.placeholder.shell": "Shell-Befehl eingeben...",
|
||||
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Fragen Sie alles...",
|
||||
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
|
||||
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -458,6 +459,9 @@ export const dict = {
|
||||
"session.messages.loading": "Lade Nachrichten...",
|
||||
"session.messages.jumpToLatest": "Zum neuesten springen",
|
||||
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||
|
||||
@@ -224,6 +224,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Enter shell command...",
|
||||
"prompt.placeholder.normal": 'Ask anything... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Ask anything...",
|
||||
"prompt.placeholder.summarizeComments": "Summarize comments…",
|
||||
"prompt.placeholder.summarizeComment": "Summarize comment…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -266,7 +267,7 @@ export const dict = {
|
||||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Attach file",
|
||||
"prompt.action.attachFile": "Add file",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
@@ -504,6 +505,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Jump to latest",
|
||||
|
||||
"session.context.addToContext": "Add {{selection}} to context",
|
||||
"session.todo.title": "Todos",
|
||||
"session.todo.collapse": "Collapse",
|
||||
"session.todo.expand": "Expand",
|
||||
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Introduce comando de shell...",
|
||||
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Pregunta cualquier cosa...",
|
||||
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
|
||||
"prompt.placeholder.summarizeComment": "Resumir comentario…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -506,6 +507,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Ir al último",
|
||||
|
||||
"session.context.addToContext": "Añadir {{selection}} al contexto",
|
||||
"session.todo.title": "Tareas",
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||
|
||||
@@ -206,6 +206,7 @@ export const dict = {
|
||||
"common.attachment": "pièce jointe",
|
||||
"prompt.placeholder.shell": "Entrez une commande shell...",
|
||||
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Demandez n'importe quoi...",
|
||||
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
|
||||
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -456,6 +457,9 @@ export const dict = {
|
||||
"session.messages.loading": "Chargement des messages...",
|
||||
"session.messages.jumpToLatest": "Aller au dernier",
|
||||
"session.context.addToContext": "Ajouter {{selection}} au contexte",
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||
|
||||
@@ -205,6 +205,7 @@ export const dict = {
|
||||
"common.attachment": "添付ファイル",
|
||||
"prompt.placeholder.shell": "シェルコマンドを入力...",
|
||||
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
|
||||
"prompt.placeholder.simple": "何でも聞いてください...",
|
||||
"prompt.placeholder.summarizeComments": "コメントを要約…",
|
||||
"prompt.placeholder.summarizeComment": "コメントを要約…",
|
||||
"prompt.mode.shell": "シェル",
|
||||
@@ -448,6 +449,9 @@ export const dict = {
|
||||
"session.messages.loading": "メッセージを読み込み中...",
|
||||
"session.messages.jumpToLatest": "最新へジャンプ",
|
||||
"session.context.addToContext": "{{selection}}をコンテキストに追加",
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
"session.new.worktree.create": "新しいワークツリーを作成",
|
||||
|
||||
@@ -209,6 +209,7 @@ export const dict = {
|
||||
"common.attachment": "첨부 파일",
|
||||
"prompt.placeholder.shell": "셸 명령어 입력...",
|
||||
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
|
||||
"prompt.placeholder.simple": "무엇이든 물어보세요...",
|
||||
"prompt.placeholder.summarizeComments": "댓글 요약…",
|
||||
"prompt.placeholder.summarizeComment": "댓글 요약…",
|
||||
"prompt.mode.shell": "셸",
|
||||
@@ -450,6 +451,9 @@ export const dict = {
|
||||
"session.messages.loading": "메시지 로드 중...",
|
||||
"session.messages.jumpToLatest": "최신으로 이동",
|
||||
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
"session.new.worktree.create": "새 작업 트리 생성",
|
||||
|
||||
@@ -226,6 +226,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Skriv inn shell-kommando...",
|
||||
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Spør om hva som helst...",
|
||||
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
|
||||
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -506,6 +507,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Hopp til nyeste",
|
||||
|
||||
"session.context.addToContext": "Legg til {{selection}} i kontekst",
|
||||
"session.todo.title": "Oppgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Utvid",
|
||||
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||
|
||||
@@ -207,6 +207,7 @@ export const dict = {
|
||||
"common.attachment": "załącznik",
|
||||
"prompt.placeholder.shell": "Wpisz polecenie terminala...",
|
||||
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Zapytaj o cokolwiek...",
|
||||
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
|
||||
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
|
||||
"prompt.mode.shell": "Terminal",
|
||||
@@ -449,6 +450,9 @@ export const dict = {
|
||||
"session.messages.loading": "Ładowanie wiadomości...",
|
||||
"session.messages.jumpToLatest": "Przejdź do najnowszych",
|
||||
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "Введите команду оболочки...",
|
||||
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Спросите что угодно...",
|
||||
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
|
||||
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
|
||||
"prompt.mode.shell": "Оболочка",
|
||||
@@ -504,6 +505,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Перейти к последнему",
|
||||
|
||||
"session.context.addToContext": "Добавить {{selection}} в контекст",
|
||||
"session.todo.title": "Задачи",
|
||||
"session.todo.collapse": "Свернуть",
|
||||
"session.todo.expand": "Развернуть",
|
||||
|
||||
"session.new.worktree.main": "Основная ветка",
|
||||
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
|
||||
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
|
||||
"prompt.placeholder.simple": "ถามอะไรก็ได้...",
|
||||
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
|
||||
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
|
||||
"prompt.mode.shell": "เชลล์",
|
||||
@@ -501,6 +502,9 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
|
||||
|
||||
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
|
||||
"session.todo.title": "สิ่งที่ต้องทำ",
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "输入 shell 命令...",
|
||||
"prompt.placeholder.normal": '随便问点什么... "{{example}}"',
|
||||
"prompt.placeholder.simple": "随便问点什么...",
|
||||
"prompt.placeholder.summarizeComments": "总结评论…",
|
||||
"prompt.placeholder.summarizeComment": "总结该评论…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -500,6 +501,9 @@ export const dict = {
|
||||
"session.messages.loading": "正在加载消息...",
|
||||
"session.messages.jumpToLatest": "跳转到最新",
|
||||
"session.context.addToContext": "将 {{selection}} 添加到上下文",
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
"session.new.worktree.create": "创建新的 worktree",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
|
||||
"prompt.placeholder.shell": "輸入 shell 命令...",
|
||||
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
|
||||
"prompt.placeholder.simple": "隨便問點什麼...",
|
||||
"prompt.placeholder.summarizeComments": "摘要評論…",
|
||||
"prompt.placeholder.summarizeComment": "摘要這則評論…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
@@ -497,6 +498,9 @@ export const dict = {
|
||||
|
||||
"session.messages.jumpToLatest": "跳到最新",
|
||||
"session.context.addToContext": "將 {{selection}} 新增到上下文",
|
||||
"session.todo.title": "待辦事項",
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
|
||||
|
||||
@@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
|
||||
@@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
|
||||
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
|
||||
"flex-1 min-w-0": panelProps.mobile,
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
@@ -1725,8 +1725,8 @@ export default function Layout(props: ParentProps) {
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => renameProject(p(), next)}
|
||||
class="text-16-medium text-text-strong truncate"
|
||||
displayClass="text-16-medium text-text-strong truncate"
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
|
||||
@@ -2042,7 +2042,7 @@ export default function Layout(props: ParentProps) {
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
|
||||
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const SidebarContent = (props: {
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
|
||||
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
|
||||
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
||||
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
||||
</SortableProvider>
|
||||
@@ -78,7 +78,7 @@ export const SidebarContent = (props: {
|
||||
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
||||
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
|
||||
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
||||
<IconButton
|
||||
icon="settings-gear"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Dynamic } from "solid-js/web"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
@@ -20,9 +19,11 @@ import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import FileTree from "@/components/file-tree"
|
||||
@@ -34,6 +35,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
@@ -89,6 +91,7 @@ export default function Page() {
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const terminal = useTerminal()
|
||||
const dialog = useDialog()
|
||||
const codeComponent = useCodeComponent()
|
||||
@@ -99,6 +102,7 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const permission = usePermission()
|
||||
|
||||
const permRequest = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
@@ -229,7 +233,7 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 1024px)")
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
@@ -269,7 +273,6 @@ export default function Page() {
|
||||
if (!path) return
|
||||
file.load(path)
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -554,13 +557,11 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
activeDraggable: undefined as string | undefined,
|
||||
activeTerminalDraggable: undefined as string | undefined,
|
||||
expanded: {} as Record<string, boolean>,
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
promptHeight: 0,
|
||||
})
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
@@ -651,6 +652,7 @@ export default function Page() {
|
||||
const idle = { type: "idle" as const }
|
||||
let inputRef!: HTMLDivElement
|
||||
let promptDock: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
|
||||
@@ -673,7 +675,8 @@ export default function Page() {
|
||||
sdk.directory
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync.session.sync(id)
|
||||
void sync.session.sync(id)
|
||||
void sync.session.todo(id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -726,13 +729,17 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
const todos = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return globalSync.data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("expanded", {})
|
||||
setStore("changes", "session")
|
||||
setUi("autoCreated", false)
|
||||
},
|
||||
@@ -751,12 +758,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = lastUserMessage()?.id
|
||||
if (!id) return
|
||||
setStore("expanded", id, status().type !== "idle")
|
||||
})
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
@@ -767,6 +768,11 @@ export default function Page() {
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
@@ -806,8 +812,8 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't autofocus chat if terminal panel is open
|
||||
if (view().terminal.opened()) return
|
||||
// Don't autofocus chat if desktop terminal panel is open
|
||||
if (isDesktop() && view().terminal.opened()) return
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
||||
@@ -905,11 +911,29 @@ export default function Page() {
|
||||
const focusInput = () => inputRef?.focus()
|
||||
|
||||
useSessionCommands({
|
||||
activeMessage,
|
||||
command,
|
||||
dialog,
|
||||
file,
|
||||
language,
|
||||
local,
|
||||
permission,
|
||||
prompt,
|
||||
sdk,
|
||||
sync,
|
||||
terminal,
|
||||
layout,
|
||||
params,
|
||||
navigate,
|
||||
tabs,
|
||||
view,
|
||||
info,
|
||||
status,
|
||||
userMessages,
|
||||
visibleUserMessages,
|
||||
showAllFiles,
|
||||
navigateMessageByOffset,
|
||||
setExpanded: (id, fn) => setStore("expanded", id, fn),
|
||||
setActiveMessage,
|
||||
addSelectionToContext,
|
||||
focusInput,
|
||||
})
|
||||
|
||||
@@ -933,7 +957,6 @@ export default function Page() {
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="large"
|
||||
triggerStyle={{ "font-size": "var(--font-size-large)" }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1421,12 +1444,12 @@ export default function Page() {
|
||||
({ height }) => {
|
||||
const next = Math.ceil(height)
|
||||
|
||||
if (next === store.promptHeight) return
|
||||
if (next === dockHeight) return
|
||||
|
||||
const el = scroller
|
||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
|
||||
|
||||
setStore("promptHeight", next)
|
||||
dockHeight = next
|
||||
|
||||
if (stick && el) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1524,13 +1547,7 @@ export default function Page() {
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<SessionHeader />
|
||||
<div
|
||||
class="flex-1 min-h-0 flex"
|
||||
classList={{
|
||||
"flex-col": !isDesktop(),
|
||||
"flex-row": isDesktop(),
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
<SessionMobileTabs
|
||||
open={!isDesktop() && !!params.id}
|
||||
mobileTab={store.mobileTab}
|
||||
@@ -1545,12 +1562,11 @@ export default function Page() {
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||
"flex-1 pt-2 md:pt-3": true,
|
||||
"flex-1": true,
|
||||
"md:flex-none": desktopSidePanelOpen(),
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
@@ -1562,7 +1578,7 @@ export default function Page() {
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
@@ -1627,8 +1643,6 @@ export default function Page() {
|
||||
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||
}}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
expanded={store.expanded}
|
||||
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1659,6 +1673,7 @@ export default function Page() {
|
||||
questionRequest={questionRequest}
|
||||
permissionRequest={permRequest}
|
||||
blocked={blocked()}
|
||||
todos={todos()}
|
||||
promptReady={prompt.ready()}
|
||||
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
|
||||
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
|
||||
@@ -1731,7 +1746,7 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<TerminalPanel
|
||||
open={view().terminal.opened()}
|
||||
open={isDesktop() && view().terminal.opened()}
|
||||
height={layout.terminal.height()}
|
||||
resize={layout.terminal.resize}
|
||||
close={view().terminal.close}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
|
||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
@@ -88,8 +88,6 @@ export function MessageTimeline(props: {
|
||||
onUnregisterMessage: (id: string) => void
|
||||
onFirstTurnMount?: () => void
|
||||
lastUserMessageID?: string
|
||||
expanded: Record<string, boolean>
|
||||
onToggleExpanded: (id: string) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
@@ -100,7 +98,7 @@ export function MessageTimeline(props: {
|
||||
>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
||||
@@ -164,14 +162,15 @@ export function MessageTimeline(props: {
|
||||
<Show when={props.showHeader}>
|
||||
<div
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"px-4 md:px-6": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-4 md:pl-4 md:pr-6": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={props.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
@@ -185,7 +184,10 @@ export function MessageTimeline(props: {
|
||||
<Show
|
||||
when={props.titleState.editing}
|
||||
fallback={
|
||||
<h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={props.openTitleEditor}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
}
|
||||
@@ -194,7 +196,8 @@ export function MessageTimeline(props: {
|
||||
ref={props.titleRef}
|
||||
value={props.titleState.draft}
|
||||
disabled={props.titleState.saving}
|
||||
class="text-16-medium text-text-strong grow-1 min-w-0"
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -215,19 +218,24 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center">
|
||||
<DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
|
||||
<Tooltip value={props.t("common.moreOptions")} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={props.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={props.titleState.menuOpen}
|
||||
onOpenChange={props.onTitleMenuOpen}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={props.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.titleState.pendingRename) return
|
||||
event.preventDefault()
|
||||
@@ -263,7 +271,7 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -316,8 +324,6 @@ export function MessageTimeline(props: {
|
||||
sessionID={props.sessionID}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
stepsExpanded={props.expanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { BasicTool } from "@opencode-ai/ui/basic-tool"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { QuestionDock } from "@/components/question-dock"
|
||||
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
|
||||
import { SessionTodoDock } from "@/components/session-todo-dock"
|
||||
|
||||
export function SessionPromptDock(props: {
|
||||
centered: boolean
|
||||
questionRequest: () => QuestionRequest | undefined
|
||||
permissionRequest: () => { patterns: string[]; permission: string } | undefined
|
||||
blocked: boolean
|
||||
todos: Todo[]
|
||||
promptReady: boolean
|
||||
handoffPrompt?: string
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
@@ -22,10 +23,88 @@ export function SessionPromptDock(props: {
|
||||
onSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const done = createMemo(
|
||||
() =>
|
||||
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const [dock, setDock] = createSignal(props.todos.length > 0)
|
||||
const [closing, setClosing] = createSignal(false)
|
||||
const [opening, setOpening] = createSignal(false)
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setDock(false)
|
||||
setClosing(false)
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.todos.length, done()] as const,
|
||||
([count, complete], prev) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
if (count === 0) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setDock(false)
|
||||
setClosing(false)
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!complete) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const wasHidden = !dock() || closing()
|
||||
setDock(true)
|
||||
setClosing(false)
|
||||
if (wasHidden) {
|
||||
setOpening(true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setOpening(false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (prev && prev[1]) {
|
||||
if (closing() && !timer) scheduleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setDock(true)
|
||||
setOpening(false)
|
||||
setClosing(true)
|
||||
scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
data-component="session-prompt-dock"
|
||||
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
@@ -35,18 +114,8 @@ export function SessionPromptDock(props: {
|
||||
>
|
||||
<Show when={props.questionRequest()} keyed>
|
||||
{(req) => {
|
||||
const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
|
||||
return (
|
||||
<div data-component="tool-part-wrapper" data-question="true" class="mb-3">
|
||||
<BasicTool
|
||||
icon="bubble-5"
|
||||
locked
|
||||
defaultOpen
|
||||
trigger={{
|
||||
title: props.t("ui.tool.questions"),
|
||||
subtitle,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<QuestionDock request={req} />
|
||||
</div>
|
||||
)
|
||||
@@ -122,12 +191,39 @@ export function SessionPromptDock(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !closing(),
|
||||
"max-h-0 pointer-events-none": closing(),
|
||||
"opacity-0 translate-y-9": closing() || opening(),
|
||||
"opacity-100 translate-y-0": !closing() && !opening(),
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.todos}
|
||||
title={props.t("session.todo.title")}
|
||||
collapseLabel={props.t("session.todo.collapse")}
|
||||
expandLabel={props.t("session.todo.expand")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": dock() && !closing(),
|
||||
"mt-0": !dock() || closing(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
activeMessage: () => UserMessage | undefined
|
||||
command: ReturnType<typeof useCommand>
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
file: ReturnType<typeof useFile>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
local: ReturnType<typeof useLocal>
|
||||
permission: ReturnType<typeof usePermission>
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
params: ReturnType<typeof useParams>
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
|
||||
status: () => { type: string }
|
||||
userMessages: () => UserMessage[]
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
showAllFiles: () => void
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||
focusInput: () => void
|
||||
}
|
||||
|
||||
@@ -37,88 +55,45 @@ const withCategory = (category: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const idle = { type: "idle" as const }
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||
const end = Math.max(selection.startLine, selection.endLine)
|
||||
const lines = content.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return undefined
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
const fileCommand = withCategory(language.t("command.category.file"))
|
||||
const contextCommand = withCategory(language.t("command.category.context"))
|
||||
const viewCommand = withCategory(language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||
export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const sessionCommand = withCategory(input.language.t("command.category.session"))
|
||||
const fileCommand = withCategory(input.language.t("command.category.file"))
|
||||
const contextCommand = withCategory(input.language.t("command.category.context"))
|
||||
const viewCommand = withCategory(input.language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(input.language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(input.language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
|
||||
|
||||
const sessionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
title: input.language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
||||
}),
|
||||
])
|
||||
|
||||
const fileCommands = createMemo(() => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
title: input.language.t("command.file.open"),
|
||||
description: input.language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
title: input.language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !tabs().active(),
|
||||
disabled: !input.tabs().active(),
|
||||
onSelect: () => {
|
||||
const active = tabs().active()
|
||||
const active = input.tabs().active()
|
||||
if (!active) return
|
||||
tabs().close(active)
|
||||
input.tabs().close(active)
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -126,30 +101,30 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const contextCommands = createMemo(() => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
title: input.language.t("command.context.addSelection"),
|
||||
description: input.language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext({
|
||||
active: tabs().active(),
|
||||
pathFromTab: file.pathFromTab,
|
||||
selectedLines: file.selectedLines,
|
||||
active: input.tabs().active(),
|
||||
pathFromTab: input.file.pathFromTab,
|
||||
selectedLines: input.file.selectedLines,
|
||||
}),
|
||||
onSelect: () => {
|
||||
const active = tabs().active()
|
||||
const active = input.tabs().active()
|
||||
if (!active) return
|
||||
const path = file.pathFromTab(active)
|
||||
const path = input.file.pathFromTab(active)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
title: input.language.t("toast.context.noLineSelection.title"),
|
||||
description: input.language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
input.addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -157,50 +132,37 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const viewCommands = createMemo(() => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
title: input.language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
onSelect: () => input.view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
title: input.language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
onSelect: () => input.view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
title: input.language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
onSelect: () => input.layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
title: input.language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => args.focusInput(),
|
||||
onSelect: () => input.focusInput(),
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
title: input.language.t("command.terminal.new"),
|
||||
description: input.language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "steps.toggle",
|
||||
title: language.t("command.steps.toggle"),
|
||||
description: language.t("command.steps.toggle.description"),
|
||||
keybind: "mod+e",
|
||||
slash: "steps",
|
||||
disabled: !params.id,
|
||||
onSelect: () => {
|
||||
const msg = args.activeMessage()
|
||||
if (!msg) return
|
||||
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
||||
if (input.terminal.all().length > 0) input.terminal.new()
|
||||
input.view().terminal.open()
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -208,61 +170,61 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const messageCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
title: input.language.t("command.message.previous"),
|
||||
description: input.language.t("command.message.previous.description"),
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !params.id,
|
||||
onSelect: () => args.navigateMessageByOffset(-1),
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
title: input.language.t("command.message.next"),
|
||||
description: input.language.t("command.message.next.description"),
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !params.id,
|
||||
onSelect: () => args.navigateMessageByOffset(1),
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(1),
|
||||
}),
|
||||
])
|
||||
|
||||
const agentCommands = createMemo(() => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
title: input.language.t("command.model.choose"),
|
||||
description: input.language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
title: input.language.t("command.mcp.toggle"),
|
||||
description: input.language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
title: input.language.t("command.agent.cycle"),
|
||||
description: input.language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
onSelect: () => input.local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
title: input.language.t("command.agent.cycle.reverse"),
|
||||
description: input.language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
onSelect: () => input.local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
title: input.language.t("command.model.variant.cycle"),
|
||||
description: input.language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
input.local.model.variant.cycle()
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -271,22 +233,22 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title:
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
||||
? input.language.t("command.permissions.autoaccept.disable")
|
||||
: input.language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
|
||||
showToast({
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.title")
|
||||
: input.language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.description")
|
||||
: input.language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -295,71 +257,71 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const sessionActionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
title: input.language.t("command.session.undo"),
|
||||
description: input.language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
if (input.status()?.type !== "idle") {
|
||||
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
const revert = input.info()?.revert?.messageID
|
||||
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = input.sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
|
||||
input.prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
args.setActiveMessage(priorMessage)
|
||||
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
||||
input.setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
title: input.language.t("command.session.redo"),
|
||||
description: input.language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
const revertMessageID = input.info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
args.setActiveMessage(lastMsg)
|
||||
await input.sdk.client.session.unrevert({ sessionID })
|
||||
input.prompt.reset()
|
||||
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
|
||||
input.setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
args.setActiveMessage(priorMsg)
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
||||
input.setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
title: input.language.t("command.session.compact"),
|
||||
description: input.language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
const model = local.model.current()
|
||||
const model = input.local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
title: input.language.t("toast.model.none.title"),
|
||||
description: input.language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await sdk.client.session.summarize({
|
||||
await input.sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
@@ -368,27 +330,29 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
title: input.language.t("command.session.fork"),
|
||||
description: input.language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: () => input.dialog.show(() => <DialogFork />),
|
||||
}),
|
||||
])
|
||||
|
||||
const shareCommands = createMemo(() => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
if (input.sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
title: input.info()?.share?.url
|
||||
? input.language.t("session.share.copy.copyLink")
|
||||
: input.language.t("command.session.share"),
|
||||
description: input.info()?.share?.url
|
||||
? input.language.t("toast.session.share.success.description")
|
||||
: input.language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
disabled: !input.params.id,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
if (!input.params.id) return
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -418,7 +382,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -426,27 +390,27 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
? input.language.t("session.share.copy.copied")
|
||||
: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const existing = info()?.share?.url
|
||||
const existing = input.info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
const url = await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -457,25 +421,25 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
title: input.language.t("command.session.unshare"),
|
||||
description: input.language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
disabled: !input.params.id || !input.info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.unshare({ sessionID: input.params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
title: input.language.t("toast.session.unshare.success.title"),
|
||||
description: input.language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
title: input.language.t("toast.session.unshare.failed.title"),
|
||||
description: input.language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
@@ -484,7 +448,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
]
|
||||
})
|
||||
|
||||
command.register("session", () =>
|
||||
input.command.register("session", () =>
|
||||
[
|
||||
sessionCommands(),
|
||||
fileCommands(),
|
||||
@@ -495,6 +459,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
permissionCommands(),
|
||||
sessionActionCommands(),
|
||||
shareCommands(),
|
||||
].flatMap((section) => section),
|
||||
].flatMap((x) => x),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -174,21 +174,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input:-moz-autofill {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
@@ -1249,4 +1234,19 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input:-moz-autofill {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -9,7 +9,7 @@ const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 20
|
||||
const PARTS = 30
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ZenData } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
const PARTS = 20
|
||||
const PARTS = 30
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const lines = models.split("\n")
|
||||
|
||||
@@ -102,7 +102,17 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS17.value +
|
||||
Resource.ZEN_MODELS18.value +
|
||||
Resource.ZEN_MODELS19.value +
|
||||
Resource.ZEN_MODELS20.value,
|
||||
Resource.ZEN_MODELS20.value +
|
||||
Resource.ZEN_MODELS21.value +
|
||||
Resource.ZEN_MODELS22.value +
|
||||
Resource.ZEN_MODELS23.value +
|
||||
Resource.ZEN_MODELS24.value +
|
||||
Resource.ZEN_MODELS25.value +
|
||||
Resource.ZEN_MODELS26.value +
|
||||
Resource.ZEN_MODELS27.value +
|
||||
Resource.ZEN_MODELS28.value +
|
||||
Resource.ZEN_MODELS29.value +
|
||||
Resource.ZEN_MODELS30.value,
|
||||
)
|
||||
const { models, providers, providerFamilies } = ModelsSchema.parse(json)
|
||||
return {
|
||||
|
||||
40
packages/console/core/sst-env.d.ts
vendored
40
packages/console/core/sst-env.d.ts
vendored
@@ -181,10 +181,50 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
40
packages/console/function/sst-env.d.ts
vendored
40
packages/console/function/sst-env.d.ts
vendored
@@ -181,10 +181,50 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
40
packages/console/resource/sst-env.d.ts
vendored
40
packages/console/resource/sst-env.d.ts
vendored
@@ -181,10 +181,50 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -317,9 +317,12 @@ pub fn spawn_command(
|
||||
cmd
|
||||
};
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
|
||||
let mut wrap = CommandWrap::from(cmd);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -224,7 +224,6 @@ export default function () {
|
||||
{iife(() => {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
expandedSteps: {} as Record<string, boolean>,
|
||||
})
|
||||
const messages = createMemo(() =>
|
||||
data().sessionID
|
||||
@@ -296,10 +295,7 @@ export default function () {
|
||||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
sessionTitle={info().title}
|
||||
messageID={message.id}
|
||||
stepsExpanded={store.expandedSteps[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
@@ -375,13 +371,6 @@ export default function () {
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
stepsExpanded={
|
||||
store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
|
||||
}
|
||||
onStepsExpandedToggle={() => {
|
||||
const id = store.messageId ?? firstUserMessage()!.id!
|
||||
setStore("expandedSteps", id, (v) => !v)
|
||||
}}
|
||||
classes={{
|
||||
root: "grow",
|
||||
content: "flex flex-col justify-between",
|
||||
|
||||
40
packages/enterprise/sst-env.d.ts
vendored
40
packages/enterprise/sst-env.d.ts
vendored
@@ -181,10 +181,50 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.5"
|
||||
version = "1.2.6"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
40
packages/function/sst-env.d.ts
vendored
40
packages/function/sst-env.d.ts
vendored
@@ -181,10 +181,50 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS21": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS22": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS23": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS24": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS25": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS26": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS27": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS28": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS29": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS3": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS30": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS4": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -44,6 +44,16 @@ opencode acp
|
||||
opencode acp --cwd /path/to/project
|
||||
```
|
||||
|
||||
### Question Tool Opt-In
|
||||
|
||||
ACP excludes `QuestionTool` by default.
|
||||
|
||||
```bash
|
||||
OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp
|
||||
```
|
||||
|
||||
Enable this only for ACP clients that support interactive question prompts.
|
||||
|
||||
### Programmatic
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -21,7 +21,6 @@ export class ACPSessionManager {
|
||||
const session = await this.sdk.session
|
||||
.create(
|
||||
{
|
||||
title: `ACP Session ${crypto.randomUUID()}`,
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
|
||||
@@ -63,6 +63,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -366,6 +366,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -38,10 +38,34 @@ function pagerCmd(): string[] {
|
||||
export const SessionCommand = cmd({
|
||||
command: "session",
|
||||
describe: "manage sessions",
|
||||
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
|
||||
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SessionDeleteCommand = cmd({
|
||||
command: "delete <sessionID>",
|
||||
describe: "delete a session",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("sessionID", {
|
||||
describe: "session ID to delete",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
try {
|
||||
await Session.get(args.sessionID)
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await Session.remove(args.sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SessionListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list sessions",
|
||||
|
||||
@@ -457,6 +457,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -532,8 +533,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -572,6 +574,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -581,6 +584,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -621,6 +625,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -636,6 +641,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -645,6 +651,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pickLatest(models: [string, Provider["models"][string]][]) {
|
||||
const picks: Record<string, [string, Provider["models"][string]]> = {}
|
||||
for (const item of models) {
|
||||
const model = item[0]
|
||||
const info = item[1]
|
||||
const key = info.family ?? model
|
||||
const prev = picks[key]
|
||||
if (!prev) {
|
||||
picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (info.release_date !== prev[1].release_date) {
|
||||
if (info.release_date > prev[1].release_date) picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (model > prev[0]) picks[key] = item
|
||||
}
|
||||
return Object.values(picks)
|
||||
}
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [all, setAll] = createSignal(false)
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
flatMap((provider) => {
|
||||
const items = pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return items
|
||||
}),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_show_all_toggle?.[0],
|
||||
title: all() ? "Show latest only" : "Show all models",
|
||||
onTrigger: () => {
|
||||
setAll((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
|
||||
@@ -247,7 +247,8 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
const fullPath = `${process.cwd()}/${item}`
|
||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const fullPath = `${baseDir}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
|
||||
@@ -75,6 +75,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -168,6 +169,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -994,23 +1006,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -441,6 +451,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -46,6 +46,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -227,12 +227,20 @@ export function Session() {
|
||||
|
||||
createEffect(() => {
|
||||
const title = Locale.truncate(session()?.title ?? "", 50)
|
||||
const pad = (text: string) => text.padEnd(10, " ")
|
||||
const weak = (text: string) => UI.Style.TEXT_DIM + pad(text) + UI.Style.TEXT_NORMAL
|
||||
const logo = UI.logo(" ").split(/\r?\n/)
|
||||
return exit.message.set(
|
||||
[
|
||||
``,
|
||||
` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`,
|
||||
` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
|
||||
` ▀▀▀▀ `,
|
||||
`${logo[0] ?? ""}`,
|
||||
`${logo[1] ?? ""}`,
|
||||
`${logo[2] ?? ""}`,
|
||||
`${logo[3] ?? ""}`,
|
||||
``,
|
||||
` ${weak("Session")}${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`,
|
||||
` ${weak("Continue")}${UI.Style.TEXT_NORMAL_BOLD}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
@@ -515,6 +523,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -529,6 +538,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -539,6 +549,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -552,6 +563,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -566,6 +578,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -574,8 +587,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -780,6 +780,7 @@ export namespace Config {
|
||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
@@ -820,7 +821,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
@@ -30,6 +30,7 @@ export namespace Flag {
|
||||
export declare const OPENCODE_CLIENT: string
|
||||
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
|
||||
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
|
||||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
@@ -49,7 +50,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
@@ -373,3 +373,12 @@ export const cljfmt: Info = {
|
||||
return Bun.which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return Bun.which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export const GlobalRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
@@ -82,7 +84,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
|
||||
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
@@ -92,7 +94,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
})
|
||||
}, 30000)
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
|
||||
@@ -501,6 +501,8 @@ export namespace Server {
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
@@ -517,7 +519,7 @@ export namespace Server {
|
||||
}
|
||||
})
|
||||
|
||||
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
@@ -525,7 +527,7 @@ export namespace Server {
|
||||
properties: {},
|
||||
}),
|
||||
})
|
||||
}, 30000)
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
|
||||
@@ -445,6 +445,12 @@ export namespace SessionPrompt {
|
||||
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
||||
return undefined
|
||||
})
|
||||
const attachments = result?.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID,
|
||||
messageID: assistantMessage.id,
|
||||
}))
|
||||
await Plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{
|
||||
@@ -467,7 +473,7 @@ export namespace SessionPrompt {
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
output: result.output,
|
||||
attachments: result.attachments,
|
||||
attachments,
|
||||
time: {
|
||||
...part.state.time,
|
||||
end: Date.now(),
|
||||
@@ -797,6 +803,15 @@ export namespace SessionPrompt {
|
||||
},
|
||||
)
|
||||
const result = await item.execute(args, ctx)
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
}
|
||||
await Plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{
|
||||
@@ -805,9 +820,9 @@ export namespace SessionPrompt {
|
||||
callID: ctx.callID,
|
||||
args,
|
||||
},
|
||||
result,
|
||||
output,
|
||||
)
|
||||
return result
|
||||
return output
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -855,16 +870,13 @@ export namespace SessionPrompt {
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: MessageV2.FilePart[] = []
|
||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") {
|
||||
textParts.push(contentItem.text)
|
||||
} else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
@@ -876,9 +888,6 @@ export namespace SessionPrompt {
|
||||
}
|
||||
if (resource.blob) {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
@@ -965,17 +974,22 @@ export namespace SessionPrompt {
|
||||
}
|
||||
using _ = defer(() => InstructionPrompt.clear(info.id))
|
||||
|
||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
||||
...part,
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
})
|
||||
|
||||
const parts = await Promise.all(
|
||||
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
|
||||
input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => {
|
||||
if (part.type === "file") {
|
||||
// before checking the protocol we check if this is an mcp resource because it needs special handling
|
||||
if (part.source?.type === "resource") {
|
||||
const { clientName, uri } = part.source
|
||||
log.info("mcp resource", { clientName, uri, mime: part.mime })
|
||||
|
||||
const pieces: MessageV2.Part[] = [
|
||||
const pieces: Draft<MessageV2.Part>[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -998,7 +1012,6 @@ export namespace SessionPrompt {
|
||||
for (const content of contents) {
|
||||
if ("text" in content && content.text) {
|
||||
pieces.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1009,7 +1022,6 @@ export namespace SessionPrompt {
|
||||
// Handle binary content if needed
|
||||
const mimeType = "mimeType" in content ? content.mimeType : part.mime
|
||||
pieces.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1021,7 +1033,6 @@ export namespace SessionPrompt {
|
||||
|
||||
pieces.push({
|
||||
...part,
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
@@ -1029,7 +1040,6 @@ export namespace SessionPrompt {
|
||||
log.error("failed to read MCP resource", { error, clientName, uri })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
pieces.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1046,7 +1056,6 @@ export namespace SessionPrompt {
|
||||
if (part.mime === "text/plain") {
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1054,7 +1063,6 @@ export namespace SessionPrompt {
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
|
||||
},
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1063,7 +1071,6 @@ export namespace SessionPrompt {
|
||||
},
|
||||
{
|
||||
...part,
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
},
|
||||
@@ -1120,9 +1127,8 @@ export namespace SessionPrompt {
|
||||
}
|
||||
const args = { filePath: filepath, offset, limit }
|
||||
|
||||
const pieces: MessageV2.Part[] = [
|
||||
const pieces: Draft<MessageV2.Part>[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1146,7 +1152,6 @@ export namespace SessionPrompt {
|
||||
}
|
||||
const result = await t.execute(args, readCtx)
|
||||
pieces.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1166,7 +1171,6 @@ export namespace SessionPrompt {
|
||||
} else {
|
||||
pieces.push({
|
||||
...part,
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
@@ -1182,7 +1186,6 @@ export namespace SessionPrompt {
|
||||
}).toObject(),
|
||||
})
|
||||
pieces.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1209,7 +1212,6 @@ export namespace SessionPrompt {
|
||||
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1217,7 +1219,6 @@ export namespace SessionPrompt {
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1226,7 +1227,6 @@ export namespace SessionPrompt {
|
||||
},
|
||||
{
|
||||
...part,
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
},
|
||||
@@ -1237,7 +1237,6 @@ export namespace SessionPrompt {
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1245,7 +1244,7 @@ export namespace SessionPrompt {
|
||||
synthetic: true,
|
||||
},
|
||||
{
|
||||
id: part.id ?? Identifier.ascending("part"),
|
||||
id: part.id,
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "file",
|
||||
@@ -1264,13 +1263,11 @@ export namespace SessionPrompt {
|
||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
...part,
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
},
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
@@ -1287,14 +1284,13 @@ export namespace SessionPrompt {
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
...part,
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
},
|
||||
]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
).then((x) => x.flat().map(assign))
|
||||
|
||||
await Plugin.trigger(
|
||||
"chat.message",
|
||||
@@ -1326,33 +1322,7 @@ export namespace SessionPrompt {
|
||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||
if (!userMessage) return input.messages
|
||||
|
||||
// Original logic when experimental plan mode is disabled
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
|
||||
if (input.agent.name === "plan") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
||||
if (wasPlan && input.agent.name === "build") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: BUILD_SWITCH,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
return input.messages
|
||||
}
|
||||
|
||||
// New plan mode logic when flag is enabled
|
||||
// Plan mode logic
|
||||
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
||||
|
||||
// Switching from plan mode to build mode
|
||||
|
||||
@@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
})
|
||||
|
||||
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
|
||||
const attachments = result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
}))
|
||||
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
@@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
output: result.output,
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
attachments: result.attachments,
|
||||
attachments,
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
@@ -127,9 +126,6 @@ export const ReadTool = Tool.define("read", {
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
|
||||
|
||||
@@ -94,10 +94,11 @@ export namespace ToolRegistry {
|
||||
async function all(): Promise<Tool.Info[]> {
|
||||
const custom = await state().then((x) => x.custom)
|
||||
const config = await Config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
InvalidTool,
|
||||
...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
|
||||
...(question ? [QuestionTool] : []),
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
@@ -114,7 +115,7 @@ export namespace ToolRegistry {
|
||||
ApplyPatchTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...(Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export namespace Tool {
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: MessageV2.FilePart[]
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
@@ -103,9 +102,6 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
metadata: {},
|
||||
attachments: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${base64Content}`,
|
||||
|
||||
@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -203,8 +203,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -50,4 +51,54 @@ describe("session.prompt missing file", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps stored part order stable when file resolution is async", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
const missing = path.join(tmp.path, "still-missing.ts")
|
||||
const msg = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "still-missing.ts",
|
||||
},
|
||||
{ type: "text", text: "after-file" },
|
||||
],
|
||||
})
|
||||
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
|
||||
const stored = await MessageV2.get({
|
||||
sessionID: session.id,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
|
||||
|
||||
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
|
||||
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
|
||||
expect(text[2]).toBe("after-file")
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user