mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-08 06:54:53 +00:00
Compare commits
27 Commits
v1.3.4
...
opencode/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8257a20aa5 | ||
|
|
c2f78224ae | ||
|
|
14f9e21d5c | ||
|
|
8e4bab5181 | ||
|
|
3c32013eb1 | ||
|
|
47d2ab120a | ||
|
|
186af2723d | ||
|
|
6926fe1c74 | ||
|
|
ee018d5c82 | ||
|
|
0465579d6b | ||
|
|
196a03caff | ||
|
|
b234370080 | ||
|
|
5d2dc8888c | ||
|
|
0b1018f6dd | ||
|
|
afb6abff73 | ||
|
|
e7f94f9b9a | ||
|
|
72c77d0e7b | ||
|
|
5c15755a10 | ||
|
|
3a4bfeb5b5 | ||
|
|
1037c72d99 | ||
|
|
ba00e9a993 | ||
|
|
963dad75ef | ||
|
|
7e9b721e97 | ||
|
|
a5b1dc081d | ||
|
|
0bc2f99f2d | ||
|
|
55895d0663 | ||
|
|
72cb9dfa31 |
5
.github/workflows/docs-locale-sync.yml
vendored
5
.github/workflows/docs-locale-sync.yml
vendored
@@ -9,7 +9,8 @@ on:
|
||||
|
||||
jobs:
|
||||
sync-locales:
|
||||
if: github.actor != 'opencode-agent[bot]'
|
||||
if: false
|
||||
#if: github.actor != 'opencode-agent[bot]'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,7 +35,7 @@ jobs:
|
||||
- name: Compute changed English docs
|
||||
id: changes
|
||||
run: |
|
||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No English docs changed in push range"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,6 +25,7 @@ target
|
||||
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
model: opencode/kimi-k2.5
|
||||
---
|
||||
|
||||
create UPCOMING_CHANGELOG.md
|
||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||
Do not preserve, merge, or reuse text from the existing file.
|
||||
|
||||
it should have sections
|
||||
Any command arguments are passed directly to `bun script/changelog.ts`.
|
||||
Use `--from` / `-f` and `--to` / `-t` to preview a specific release range.
|
||||
|
||||
```
|
||||
## TUI
|
||||
The input already contains the exact commit range since the last non-draft release.
|
||||
The commits are already filtered to the release-relevant packages and grouped into
|
||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||
The input may also include a `## Community Contributors Input` section.
|
||||
|
||||
## Desktop
|
||||
Before writing any entry you keep, inspect the real diff with
|
||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so the
|
||||
summary reflects the actual user-facing change and not just the commit message.
|
||||
Do not use `git log` or author metadata when deciding attribution.
|
||||
|
||||
## Core
|
||||
Rules:
|
||||
|
||||
## Misc
|
||||
```
|
||||
- Write the final file with sections in this order:
|
||||
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
||||
- Only include sections that have at least one notable entry
|
||||
- Keep one bullet per commit you keep
|
||||
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
||||
- Start each bullet with a capital letter
|
||||
- Prefer what changed for users over what code changed internally
|
||||
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
|
||||
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
|
||||
- If an input bullet has no `(@username)` suffix, do not add one
|
||||
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
|
||||
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
|
||||
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
|
||||
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
|
||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||
- Do not derive the thank-you section from the main summary bullets
|
||||
- Do not include the heading `## Community Contributors Input` in the final file
|
||||
|
||||
fetch the latest github release for this repository to determine the last release version.
|
||||
## Changelog Input
|
||||
|
||||
find each PR that was merged since the last release
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
!`bun script/changelog.ts $ARGUMENTS`
|
||||
|
||||
38
bun.lock
38
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -164,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -378,6 +378,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
@@ -422,7 +423,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -456,7 +457,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -467,7 +468,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -502,7 +503,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -549,7 +550,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -560,7 +561,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -593,8 +594,9 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
@@ -4485,6 +4487,8 @@
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=",
|
||||
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=",
|
||||
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=",
|
||||
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY="
|
||||
"x86_64-linux": "sha256-0JgEA54d1ZZ0IWUmxCJP2fnQ2cpmLO25G+hafUbHFLw=",
|
||||
"aarch64-linux": "sha256-oB4Ptc+MH76MEw0DZodmCCz87qOmbzi26751ZM4DYyE=",
|
||||
"aarch64-darwin": "sha256-712rb7B5gTRz1uTx4cJQSrmq9DoBUe+UxbvawYV4XTE=",
|
||||
"x86_64-darwin": "sha256-GRCiEBDDEyVx1et04xqdIEQr3ykRMOBJoQy/xddSsCA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
var key = "opencode-theme-id"
|
||||
var themeId = localStorage.getItem(key) || "oc-2"
|
||||
|
||||
if (themeId === "oc-1") {
|
||||
if (themeId.slice(0, 3) === "oc-" && themeId !== "oc-2") {
|
||||
themeId = "oc-2"
|
||||
localStorage.setItem(key, themeId)
|
||||
localStorage.removeItem("opencode-theme-css-light")
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryEntry,
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
@@ -313,17 +312,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const [history, setHistory] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
Persist.global("prompt-history.v2"),
|
||||
createStore<{
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
entries: PromptHistoryEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
)
|
||||
const [shellHistory, setShellHistory] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
Persist.global("prompt-history-shell.v2"),
|
||||
createStore<{
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
entries: PromptHistoryEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
@@ -624,17 +623,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Prompt } from "@/context/prompt"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
normalizePromptHistoryEntry,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const entry = (value: string, comments: PromptHistoryComment[] = []) => ({ prompt: text(value), comments })
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
@@ -42,7 +42,7 @@ describe("prompt-input history", () => {
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
const entries = [text("third"), text("second"), text("first")]
|
||||
const entries = [entry("third"), entry("second"), entry("first")]
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
@@ -73,12 +73,7 @@ describe("prompt-input history", () => {
|
||||
})
|
||||
|
||||
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
||||
const entries = [
|
||||
{
|
||||
prompt: text("with comment"),
|
||||
comments: [comment("c1")],
|
||||
},
|
||||
]
|
||||
const entries = [entry("with comment", [comment("c1")])]
|
||||
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
@@ -95,12 +90,6 @@ describe("prompt-input history", () => {
|
||||
expect(up.entry.comments).toEqual([comment("c1")])
|
||||
})
|
||||
|
||||
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
||||
const entry = normalizePromptHistoryEntry(text("legacy"))
|
||||
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
||||
expect(entry.comments).toEqual([])
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
|
||||
@@ -20,8 +20,6 @@ export type PromptHistoryEntry = {
|
||||
comments: PromptHistoryComment[]
|
||||
}
|
||||
|
||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
const atStart = position === 0
|
||||
@@ -59,13 +57,7 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
if (Array.isArray(entry)) {
|
||||
return {
|
||||
prompt: clonePromptParts(entry),
|
||||
comments: [],
|
||||
}
|
||||
}
|
||||
function clonePromptHistoryEntry(entry: PromptHistoryEntry): PromptHistoryEntry {
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
@@ -77,7 +69,7 @@ export function promptLength(prompt: Prompt) {
|
||||
}
|
||||
|
||||
export function prependHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
entries: PromptHistoryEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
max = MAX_HISTORY,
|
||||
@@ -112,9 +104,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
|
||||
)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
||||
const entryA = normalizePromptHistoryEntry(promptA)
|
||||
const entryB = normalizePromptHistoryEntry(promptB)
|
||||
function isPromptEqual(entryA: PromptHistoryEntry, entryB: PromptHistoryEntry) {
|
||||
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||
const partA = entryA.prompt[i]
|
||||
@@ -149,7 +139,7 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
|
||||
|
||||
type HistoryNavInput = {
|
||||
direction: "up" | "down"
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
entries: PromptHistoryEntry[]
|
||||
historyIndex: number
|
||||
currentPrompt: Prompt
|
||||
currentComments: PromptHistoryComment[]
|
||||
@@ -181,7 +171,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
}
|
||||
|
||||
if (input.historyIndex === -1) {
|
||||
const entry = normalizePromptHistoryEntry(input.entries[0])
|
||||
const entry = clonePromptHistoryEntry(input.entries[0])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: 0,
|
||||
@@ -196,7 +186,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
|
||||
if (input.historyIndex < input.entries.length - 1) {
|
||||
const next = input.historyIndex + 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
const entry = clonePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
@@ -215,7 +205,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
|
||||
if (input.historyIndex > 0) {
|
||||
const next = input.historyIndex - 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
const entry = clonePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
|
||||
@@ -167,10 +167,8 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
||||
}
|
||||
|
||||
function createCommentSession(dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "comments", [legacy]),
|
||||
Persist.scoped(dir, id, "comments"),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
|
||||
@@ -34,10 +34,8 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
|
||||
}
|
||||
|
||||
function createViewSession(dir: string, id: string | undefined) {
|
||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||
Persist.scoped(dir, id, "file-view"),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
|
||||
@@ -53,7 +53,7 @@ function createGlobalSync() {
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
Persist.global("globalSync.project"),
|
||||
createStore({ value: [] as Project[] }),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,10 +125,7 @@ export function createChildStoreManager(input: {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const vcs = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
persisted(Persist.workspace(directory, "vcs"), createStore({ value: undefined as VcsInfo | undefined })),
|
||||
)
|
||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||
const vcsStore = vcs[0]
|
||||
@@ -136,7 +133,7 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "project", ["project.v1"]),
|
||||
Persist.workspace(directory, "project"),
|
||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||
),
|
||||
)
|
||||
@@ -144,10 +141,7 @@ export function createChildStoreManager(input: {
|
||||
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||
|
||||
const icon = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "icon", ["icon.v1"]),
|
||||
createStore({ value: undefined as string | undefined }),
|
||||
),
|
||||
persisted(Persist.workspace(directory, "icon"), createStore({ value: undefined as string | undefined })),
|
||||
)
|
||||
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
||||
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
@@ -195,7 +195,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
Persist.global("language"),
|
||||
createStore({
|
||||
locale: initial,
|
||||
}),
|
||||
|
||||
@@ -123,14 +123,6 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
const path = sessionPath(key)
|
||||
return {
|
||||
all: normalizeSessionTabList(path, tabs.all),
|
||||
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
@@ -138,96 +130,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const globalSync = useGlobalSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const sidebar = value.sidebar
|
||||
const migratedSidebar = (() => {
|
||||
if (!isRecord(sidebar)) return sidebar
|
||||
if (typeof sidebar.workspaces !== "boolean") return sidebar
|
||||
return {
|
||||
...sidebar,
|
||||
workspaces: {},
|
||||
workspacesDefault: sidebar.workspaces,
|
||||
}
|
||||
})()
|
||||
|
||||
const review = value.review
|
||||
const fileTree = value.fileTree
|
||||
const migratedFileTree = (() => {
|
||||
if (!isRecord(fileTree)) return fileTree
|
||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
|
||||
return {
|
||||
...fileTree,
|
||||
opened: true,
|
||||
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
|
||||
tab: "changes",
|
||||
}
|
||||
})()
|
||||
|
||||
const migratedReview = (() => {
|
||||
if (!isRecord(review)) return review
|
||||
if (typeof review.panelOpened === "boolean") return review
|
||||
|
||||
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
|
||||
return {
|
||||
...review,
|
||||
panelOpened: opened,
|
||||
}
|
||||
})()
|
||||
|
||||
const sessionTabs = value.sessionTabs
|
||||
const migratedSessionTabs = (() => {
|
||||
if (!isRecord(sessionTabs)) return sessionTabs
|
||||
|
||||
let changed = false
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(sessionTabs).map(([key, tabs]) => {
|
||||
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
|
||||
|
||||
const current = {
|
||||
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
|
||||
active: typeof tabs.active === "string" ? tabs.active : undefined,
|
||||
}
|
||||
const normalized = normalizeStoredSessionTabs(key, current)
|
||||
if (current.all.length !== tabs.all.length) changed = true
|
||||
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
|
||||
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
|
||||
return [key, normalized]
|
||||
}),
|
||||
)
|
||||
|
||||
if (!changed) return sessionTabs
|
||||
return next
|
||||
})()
|
||||
|
||||
if (
|
||||
migratedSidebar === sidebar &&
|
||||
migratedReview === review &&
|
||||
migratedFileTree === fileTree &&
|
||||
migratedSessionTabs === sessionTabs
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
sidebar: migratedSidebar,
|
||||
review: migratedReview,
|
||||
fileTree: migratedFileTree,
|
||||
sessionTabs: migratedSessionTabs,
|
||||
}
|
||||
}
|
||||
|
||||
const target = Persist.global("layout", ["layout.v6"])
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...target, migrate },
|
||||
Persist.global("layout"),
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
@@ -270,11 +174,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
used: new Map<string, number>(),
|
||||
}
|
||||
|
||||
const SESSION_STATE_KEYS = [
|
||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||
{ key: "terminal", legacy: "terminal", version: "v1" },
|
||||
{ key: "file-view", legacy: "file", version: "v1" },
|
||||
] as const
|
||||
const SESSION_STATE_KEYS = ["comments", "file-view", "prompt", "terminal"] as const
|
||||
|
||||
const dropSessionState = (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
@@ -283,12 +183,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const session = parts[1]
|
||||
if (!dir) continue
|
||||
|
||||
for (const entry of SESSION_STATE_KEYS) {
|
||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
||||
void removePersisted(target, platform)
|
||||
|
||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||
void removePersisted({ key: legacyKey }, platform)
|
||||
for (const item of SESSION_STATE_KEYS) {
|
||||
void removePersisted(session ? Persist.session(dir, session, item) : Persist.workspace(dir, item), platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,7 +772,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const path = createMemo(() => sessionPath(key()))
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
const tabs = createMemo<SessionTabs>(() => {
|
||||
const current = store.sessionTabs[key()]
|
||||
if (!current || !Array.isArray(current.all)) return { all: [] }
|
||||
return {
|
||||
all: normalizeSessionTabList(
|
||||
path(),
|
||||
current.all.filter((tab): tab is string => typeof tab === "string"),
|
||||
),
|
||||
active: typeof current.active === "string" ? normalizeSessionTab(path(), current.active) : undefined,
|
||||
}
|
||||
})
|
||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||
return {
|
||||
@@ -903,13 +809,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session] ? tabs() : undefined, normalize(tab))
|
||||
setStore("sessionTabs", session, next)
|
||||
},
|
||||
close(tab: string) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
if (!store.sessionTabs[session]) return
|
||||
const current = tabs()
|
||||
|
||||
if (tab === "review") {
|
||||
if (current.active !== tab) return
|
||||
@@ -932,8 +838,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
if (!store.sessionTabs[session]) return
|
||||
const current = tabs()
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
|
||||
@@ -23,27 +23,10 @@ type Saved = {
|
||||
session: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const handoff = new Map<string, State>()
|
||||
|
||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return { session: {} }
|
||||
|
||||
const item = value as {
|
||||
session?: Record<string, State | undefined>
|
||||
pick?: Record<string, State | undefined>
|
||||
}
|
||||
|
||||
if (item.session && typeof item.session === "object") return { session: item.session }
|
||||
if (!item.pick || typeof item.pick !== "object") return { session: {} }
|
||||
|
||||
return {
|
||||
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
}
|
||||
}
|
||||
|
||||
const clone = (value: State | undefined) => {
|
||||
if (!value) return undefined
|
||||
return {
|
||||
@@ -66,10 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const [saved, setSaved] = persisted(
|
||||
{
|
||||
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||
migrate,
|
||||
},
|
||||
Persist.workspace(sdk.directory, "model-selection"),
|
||||
createStore<Saved>({
|
||||
session: {},
|
||||
}),
|
||||
|
||||
@@ -28,7 +28,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
const providers = useProviders()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
Persist.global("model"),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
|
||||
@@ -124,7 +124,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const currentSession = createMemo(() => params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
Persist.global("notification"),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
|
||||
@@ -25,12 +25,6 @@ describe("autoRespondsPermission", () => {
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("uses a parent session's legacy auto-accept key", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
|
||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function directoryAcceptKey(directory: string) {
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
return autoAccept[key] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
|
||||
@@ -59,23 +59,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.global("permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
Persist.global("permission"),
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
@@ -206,7 +190,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -230,8 +213,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,10 +162,8 @@ type PromptCacheEntry = {
|
||||
}
|
||||
|
||||
function createPromptSession(dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "prompt", [legacy]),
|
||||
Persist.scoped(dir, id, "prompt"),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
@@ -102,35 +101,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
Persist.global("server"),
|
||||
createStore({
|
||||
list: [] as StoredServer[],
|
||||
list: [] as ServerConnection.Http[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
}),
|
||||
)
|
||||
|
||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
const servers = [
|
||||
...(props.servers ?? []),
|
||||
...store.list.map((value) =>
|
||||
typeof value === "string"
|
||||
? {
|
||||
type: "http" as const,
|
||||
http: { url: value },
|
||||
}
|
||||
: value,
|
||||
...store.list.filter(
|
||||
(value): value is ServerConnection.Http => !!value && typeof value === "object" && value.type === "http",
|
||||
),
|
||||
]
|
||||
|
||||
const deduped = new Map(
|
||||
servers.map((value) => {
|
||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||
return [ServerConnection.key(conn), conn]
|
||||
}),
|
||||
)
|
||||
const deduped = new Map(servers.map((value) => [ServerConnection.key(value), value] as const))
|
||||
|
||||
return [...deduped.values()]
|
||||
})
|
||||
@@ -176,7 +163,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (!url_) return
|
||||
const conn = { ...input, http: { ...input.http, url: url_ } }
|
||||
return batch(() => {
|
||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||
const existing = store.list.findIndex((x) => x.http.url === url_)
|
||||
if (existing !== -1) {
|
||||
setStore("list", existing, conn)
|
||||
} else {
|
||||
@@ -188,12 +175,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const list = store.list.filter((x) => url(x) !== key)
|
||||
const list = store.list.filter((x) => ServerConnection.key(x) !== key)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
if (state.active === key) {
|
||||
const next = list[0]
|
||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
||||
setState("active", next ? ServerConnection.key(next) : props.defaultServer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
test("uses workspace-only directory cache key", () => {
|
||||
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLegacyTerminalStorageKeys", () => {
|
||||
test("keeps workspace storage path when no legacy session id", () => {
|
||||
expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
|
||||
})
|
||||
|
||||
test("includes legacy session path before workspace path", () => {
|
||||
expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
|
||||
"/repo/terminal/session-123.v1",
|
||||
"/repo/terminal.v1",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateTerminalState", () => {
|
||||
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "missing",
|
||||
all: [
|
||||
null,
|
||||
{ id: "one", title: "Terminal 2" },
|
||||
{ id: "one", title: "duplicate", titleNumber: 9 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
{ title: "no-id" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "one",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 2", titleNumber: 2 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a valid active id", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1" },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1", titleNumber: 1 },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { defaultTitle } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
|
||||
export type LocalPTY = {
|
||||
@@ -33,64 +33,28 @@ function num(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function numberFromTitle(title: string) {
|
||||
return titleNumber(title, MAX_TERMINAL_SESSIONS)
|
||||
}
|
||||
|
||||
function pty(value: unknown): LocalPTY | undefined {
|
||||
if (!record(value)) return
|
||||
function pty(value: unknown): value is LocalPTY {
|
||||
if (!record(value)) return false
|
||||
|
||||
const id = text(value.id)
|
||||
if (!id) return
|
||||
if (!id) return false
|
||||
|
||||
const title = text(value.title) ?? ""
|
||||
const title = text(value.title)
|
||||
const number = num(value.titleNumber)
|
||||
const rows = num(value.rows)
|
||||
const cols = num(value.cols)
|
||||
const buffer = text(value.buffer)
|
||||
const scrollY = num(value.scrollY)
|
||||
const cursor = num(value.cursor)
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
|
||||
...(rows !== undefined ? { rows } : {}),
|
||||
...(cols !== undefined ? { cols } : {}),
|
||||
...(buffer !== undefined ? { buffer } : {}),
|
||||
...(scrollY !== undefined ? { scrollY } : {}),
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateTerminalState(value: unknown) {
|
||||
if (!record(value)) return value
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
|
||||
const next = pty(item)
|
||||
if (!next || seen.has(next.id)) return []
|
||||
seen.add(next.id)
|
||||
return [next]
|
||||
})
|
||||
|
||||
const active = text(value.active)
|
||||
|
||||
return {
|
||||
active: active && seen.has(active) ? active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
if (!title) return false
|
||||
if (!number || number <= 0) return false
|
||||
if (value.rows !== undefined && num(value.rows) === undefined) return false
|
||||
if (value.cols !== undefined && num(value.cols) === undefined) return false
|
||||
if (value.buffer !== undefined && text(value.buffer) === undefined) return false
|
||||
if (value.scrollY !== undefined && num(value.scrollY) === undefined) return false
|
||||
if (value.cursor !== undefined && num(value.cursor) === undefined) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
|
||||
if (!legacySessionID) return [`${dir}/terminal.v1`]
|
||||
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
|
||||
}
|
||||
|
||||
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
|
||||
|
||||
type TerminalCacheEntry = {
|
||||
@@ -110,7 +74,7 @@ const trimTerminal = (pty: LocalPTY) => {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
export function clearWorkspaceTerminals(dir: string, platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
const entry = cache.get(key)
|
||||
@@ -118,26 +82,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
}
|
||||
|
||||
removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
|
||||
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
|
||||
for (const id of sessionIDs ?? []) {
|
||||
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
|
||||
legacy.add(key)
|
||||
}
|
||||
}
|
||||
for (const key of legacy) {
|
||||
removePersisted({ key }, platform)
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
Persist.workspace(dir, "terminal"),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
@@ -146,16 +95,20 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const all = store.all.filter(pty)
|
||||
const active =
|
||||
typeof store.active === "string" && all.some((item) => item.id === store.active) ? store.active : all[0]?.id
|
||||
if (all.length === store.all.length && active === store.active) return
|
||||
batch(() => {
|
||||
setStore("all", all)
|
||||
if (active !== store.active) setStore("active", active)
|
||||
})
|
||||
})
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
store.all.flatMap((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return [direct]
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
if (parsed === undefined) return []
|
||||
return [parsed]
|
||||
}),
|
||||
)
|
||||
const existingTitleNumbers = new Set(store.all.flatMap((pty) => (pty.titleNumber > 0 ? [pty.titleNumber] : [])))
|
||||
|
||||
return (
|
||||
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
||||
@@ -382,7 +335,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||
const loadWorkspace = (dir: string) => {
|
||||
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
const existing = cache.get(key)
|
||||
@@ -393,7 +346,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk, dir),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
@@ -402,7 +355,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -411,7 +364,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
loadWorkspace(prev.dir).trimAll()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -89,7 +89,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("layout.page", ["layout.page.v1"]),
|
||||
Persist.global("layout.page"),
|
||||
createStore({
|
||||
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
||||
activeProject: undefined as string | undefined,
|
||||
@@ -1567,11 +1567,7 @@ export default function Layout(props: ParentProps) {
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
clearWorkspaceTerminals(
|
||||
directory,
|
||||
sessions.map((s) => s.id),
|
||||
platform,
|
||||
)
|
||||
clearWorkspaceTerminals(directory, platform)
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
|
||||
@@ -516,7 +516,7 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
Persist.workspace(sdk.directory, "followup"),
|
||||
createStore<{
|
||||
items: Record<string, FollowupItem[] | undefined>
|
||||
failed: Record<string, string | undefined>
|
||||
|
||||
@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
|
||||
return (
|
||||
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
|
||||
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
|
||||
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Option(props: {
|
||||
multi: boolean
|
||||
picked: boolean
|
||||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{props.label}</span>
|
||||
<Show when={props.description}>
|
||||
<span data-slot="option-description">{props.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
setStore("answers", store.tab, [answer])
|
||||
if (custom) setStore("custom", store.tab, answer)
|
||||
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customUpdate(input())
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const focusCustom = (el: HTMLTextAreaElement) => {
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
resizeInput(el)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const toggleCustomMark = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
customToggle()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (sending()) return
|
||||
if (store.editing) commitCustom()
|
||||
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
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)
|
||||
}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
disabled={sending()}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<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>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<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 data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
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>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
type ScrollPos = { x: number; y: number }
|
||||
|
||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: ScrollPos | undefined
|
||||
let code: HTMLElement[] = []
|
||||
|
||||
const getCode = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const save = (next: ScrollPos) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
input.view().setScroll(input.tab(), out)
|
||||
})
|
||||
}
|
||||
|
||||
const onCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
save({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const next = getCode()
|
||||
if (next.length === code.length && next.every((el, i) => el === code[i])) return
|
||||
|
||||
for (const item of code) {
|
||||
item.removeEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
|
||||
code = next
|
||||
|
||||
for (const item of code) {
|
||||
item.addEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const pos = input.view().scroll(input.tab())
|
||||
if (!pos) return
|
||||
|
||||
sync()
|
||||
|
||||
if (code.length > 0) {
|
||||
for (const item of code) {
|
||||
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
|
||||
if (code.length > 0) return
|
||||
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restore()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (code.length === 0) sync()
|
||||
|
||||
save({
|
||||
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const setViewport = (el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restore()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of code) {
|
||||
item.removeEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
queueRestore,
|
||||
setViewport,
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
const search = {
|
||||
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
const scrollSync = createScrollSync({
|
||||
tab: () => props.tab,
|
||||
view,
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
return previewSelectedLines(source, {
|
||||
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview =
|
||||
input.preview ??
|
||||
(() => {
|
||||
if (input.file === path()) return selectionPreview(contents(), selection)
|
||||
const source = file.get(input.file)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
})()
|
||||
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||
|
||||
const saved = comments.add({
|
||||
file: input.file,
|
||||
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview =
|
||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
||||
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const getCodeScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
view().setScroll(props.tab, out)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
queueScrollUpdate({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const syncCodeScroll = () => {
|
||||
const next = getCodeScroll()
|
||||
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
codeScroll = next
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.addEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = view().scroll(props.tab)
|
||||
if (!s) return
|
||||
|
||||
syncCodeScroll()
|
||||
|
||||
if (codeScroll.length > 0) {
|
||||
for (const item of codeScroll) {
|
||||
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
if (codeScroll.length > 0) return
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restoreScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
queueScrollUpdate({
|
||||
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
scrollSync.queueRestore()
|
||||
})
|
||||
|
||||
const renderFile = (source: string) => (
|
||||
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
queueRestore()
|
||||
scrollSync.queueRestore()
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: queueRestore,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
return (
|
||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||
<ScrollView
|
||||
class="h-full"
|
||||
viewportRef={(el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
|
||||
@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
}
|
||||
command.register("session", () => {
|
||||
const share =
|
||||
sync.data.config.share === "disabled"
|
||||
? []
|
||||
: [
|
||||
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"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
const write = async (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return true
|
||||
}
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return false
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
const copyShare = async (url: string, existing: boolean) => {
|
||||
if (!(await write(url))) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
showToast({
|
||||
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
const share = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copyShare(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: 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"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID })
|
||||
.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"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: 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"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
await copyShare(url, false)
|
||||
}
|
||||
|
||||
const unshare = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: 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"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
}
|
||||
|
||||
const addSelection = () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(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]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
},
|
||||
}),
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
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"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
onSelect: share,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: unshare,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(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]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
...share,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const sessionCmds = () => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: fork,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: openFile,
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
]
|
||||
|
||||
const contextCmds = () => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: addSelection,
|
||||
}),
|
||||
]
|
||||
|
||||
const viewCmds = () => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
]
|
||||
|
||||
const terminalCmds = () => [
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: openTerminal,
|
||||
}),
|
||||
]
|
||||
|
||||
const messageCmds = () => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
]
|
||||
|
||||
const modelCmds = () => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: chooseModel,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpCmds = () => [
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: chooseMcp,
|
||||
}),
|
||||
]
|
||||
|
||||
const agentCmds = () => [
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
const permissionsCmds = () => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: toggleAutoAccept,
|
||||
}),
|
||||
]
|
||||
|
||||
command.register("session", () => [
|
||||
...sessionCmds(),
|
||||
...shareCmds(),
|
||||
...fileCmds(),
|
||||
...contextCmds(),
|
||||
...viewCmds(),
|
||||
...terminalCmds(),
|
||||
...messageCmds(),
|
||||
...modelCmds(),
|
||||
...mcpCmds(),
|
||||
...agentCmds(),
|
||||
...permissionsCmds(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe("theme preload", () => {
|
||||
test("migrates legacy oc-1 to oc-2 before mount", () => {
|
||||
test("resets unsupported built-in theme ids before mount", () => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||
@@ -28,10 +28,9 @@ describe("theme preload", () => {
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("oc-2")
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
|
||||
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
||||
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
|
||||
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
||||
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
||||
})
|
||||
|
||||
test("keeps cached css for non-default themes", () => {
|
||||
|
||||
@@ -15,11 +15,8 @@ type PersistedWithReady<T> = [
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
key: string
|
||||
legacy?: string[]
|
||||
migrate?: (value: unknown) => unknown
|
||||
}
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = new Map<string, boolean>()
|
||||
@@ -200,11 +197,10 @@ function parse(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
|
||||
function normalize(defaults: unknown, raw: string) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return
|
||||
const migrated = migrate ? migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const merged = merge(defaults, parsed)
|
||||
return JSON.stringify(merged)
|
||||
}
|
||||
|
||||
@@ -309,18 +305,18 @@ export const PersistTesting = {
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
global(key: string): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key }
|
||||
},
|
||||
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
|
||||
workspace(dir: string, key: string): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `workspace:${key}` }
|
||||
},
|
||||
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
|
||||
session(dir: string, session: string, key: string): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `session:${session}:${key}` }
|
||||
},
|
||||
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
||||
if (session) return Persist.session(dir, session, key, legacy)
|
||||
return Persist.workspace(dir, key, legacy)
|
||||
scoped(dir: string, session: string | undefined, key: string): PersistTarget {
|
||||
if (session) return Persist.session(dir, session, key)
|
||||
return Persist.workspace(dir, key)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -347,7 +343,6 @@ export function persisted<T>(
|
||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||
|
||||
const defaults = snapshot(store[0])
|
||||
const legacy = config.legacy ?? []
|
||||
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
|
||||
@@ -357,22 +352,15 @@ export function persisted<T>(
|
||||
return localStorageWithPrefix(config.storage)
|
||||
})()
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorageDirect()
|
||||
if (!config.storage) return platform.storage?.()
|
||||
return platform.storage?.(LEGACY_STORAGE)
|
||||
})()
|
||||
|
||||
const storage = (() => {
|
||||
if (!isDesktop) {
|
||||
const current = currentStorage as SyncStorage
|
||||
const legacyStore = legacyStorage as SyncStorage
|
||||
|
||||
const api: SyncStorage = {
|
||||
getItem: (key) => {
|
||||
const raw = current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
const next = normalize(defaults, raw)
|
||||
if (next === undefined) {
|
||||
current.removeItem(key)
|
||||
return null
|
||||
@@ -381,20 +369,6 @@ export function persisted<T>(
|
||||
return next
|
||||
}
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
legacyStore.removeItem(legacyKey)
|
||||
continue
|
||||
}
|
||||
current.setItem(key, next)
|
||||
legacyStore.removeItem(legacyKey)
|
||||
return next
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
@@ -409,13 +383,12 @@ export function persisted<T>(
|
||||
}
|
||||
|
||||
const current = currentStorage as AsyncStorage
|
||||
const legacyStore = legacyStorage as AsyncStorage | undefined
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
const raw = await current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const next = normalize(defaults, raw, config.migrate)
|
||||
const next = normalize(defaults, raw)
|
||||
if (next === undefined) {
|
||||
await current.removeItem(key).catch(() => undefined)
|
||||
return null
|
||||
@@ -424,22 +397,6 @@ export function persisted<T>(
|
||||
return next
|
||||
}
|
||||
|
||||
if (!legacyStore) return null
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = await legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
const next = normalize(defaults, legacyRaw, config.migrate)
|
||||
if (next === undefined) {
|
||||
await legacyStore.removeItem(legacyKey).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
await current.setItem(key, next)
|
||||
await legacyStore.removeItem(legacyKey)
|
||||
return next
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -432,9 +432,7 @@ export default function Home() {
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q5")}>
|
||||
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>
|
||||
</Faq>
|
||||
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.4"
|
||||
version = "1.3.7"
|
||||
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.3.4/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.4/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.4/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/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.3.4/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/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.3.4/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -142,6 +142,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
|
||||
@@ -140,6 +140,8 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
||||
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
||||
- Without `--force`, an already-configured npm package name is a no-op.
|
||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||
@@ -164,7 +166,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- `api.app.version`
|
||||
- `api.command.register(cb)` / `api.command.trigger(value)`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
|
||||
- `api.keybind.match`, `print`, `create`
|
||||
- `api.tuiConfig`
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
@@ -210,6 +212,7 @@ Command behavior:
|
||||
|
||||
- `ui.Dialog` is the base dialog wrapper.
|
||||
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||
- `ui.Prompt` renders the same prompt component used by the host app.
|
||||
- `ui.toast(...)` shows a toast.
|
||||
- `ui.dialog` exposes the host dialog stack:
|
||||
- `replace(render, onClose?)`
|
||||
@@ -266,7 +269,9 @@ Theme install behavior:
|
||||
|
||||
- Relative theme paths are resolved from the plugin root.
|
||||
- Theme name is the JSON basename.
|
||||
- Install is skipped if that theme name already exists.
|
||||
- First install writes only when the destination file is missing.
|
||||
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
|
||||
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
|
||||
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||
- Global plugins persist installed themes under the global `themes` dir.
|
||||
- Invalid or unreadable theme files are ignored.
|
||||
@@ -277,6 +282,7 @@ Current host slot names:
|
||||
|
||||
- `app`
|
||||
- `home_logo`
|
||||
- `home_prompt` with props `{ workspace_id? }`
|
||||
- `home_bottom`
|
||||
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||
- `sidebar_content` with props `{ session_id }`
|
||||
@@ -289,7 +295,7 @@ Slot notes:
|
||||
- `api.slots.register(plugin)` does not return an unregister function.
|
||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||
- Plugin-provided `id` is not allowed.
|
||||
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- Plugins cannot define new slot names in this branch.
|
||||
|
||||
### Plugin control and lifecycle
|
||||
|
||||
@@ -581,10 +581,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch model variant",
|
||||
title: "Variant cycle",
|
||||
value: "variant.cycle",
|
||||
keybind: "variant_cycle",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch model variant",
|
||||
value: "variant.list",
|
||||
category: "Agent",
|
||||
hidden: local.model.variant.list().length === 0,
|
||||
slash: {
|
||||
name: "variants",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
},
|
||||
|
||||
@@ -136,7 +136,13 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
if (local.model.variant.list().length > 0) {
|
||||
const list = local.model.variant.list()
|
||||
const cur = local.model.variant.selected()
|
||||
if (cur === "default" || (cur && list.includes(cur))) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (list.length > 0) {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,23 +8,32 @@ export function DialogVariant() {
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(undefined)
|
||||
},
|
||||
},
|
||||
}))
|
||||
...local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.current()}
|
||||
current={local.model.variant.selected()}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
selected() {
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key]
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (!this.list().includes(v)) return undefined
|
||||
return v
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
if (!m) return []
|
||||
@@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const m = currentModel()
|
||||
if (!m) return
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
setModelStore("variant", key, value)
|
||||
setModelStore("variant", key, value ?? "default")
|
||||
save()
|
||||
},
|
||||
cycle() {
|
||||
|
||||
@@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
|
||||
return true
|
||||
}
|
||||
|
||||
export function upsertTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (customThemes[name] !== undefined) {
|
||||
customThemes[name] = theme
|
||||
} else {
|
||||
pluginThemes[name] = theme
|
||||
}
|
||||
syncThemes()
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { Global } from "@/global"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const dir = createMemo(() => {
|
||||
const dir = props.api.state.path.directory || process.cwd()
|
||||
const out = dir.replace(Global.Path.home, "~")
|
||||
const branch = props.api.state.vcs?.branch
|
||||
if (branch) return out + ":" + branch
|
||||
return out
|
||||
})
|
||||
|
||||
return <text fg={theme().textMuted}>{dir()}</text>
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.mcp())
|
||||
const has = createMemo(() => list().length > 0)
|
||||
const err = createMemo(() => list().some((item) => item.status === "failed"))
|
||||
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
|
||||
|
||||
return (
|
||||
<Show when={has()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme().text}>
|
||||
<Switch>
|
||||
<Match when={err()}>
|
||||
<span style={{ fg: theme().error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={theme().textMuted}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Version(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme().textMuted}>{props.api.app.version}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory api={props.api} />
|
||||
<Mcp api={props.api} />
|
||||
<box flexGrow={1} />
|
||||
<Version api={props.api} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
home_footer() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -1,3 +1,4 @@
|
||||
import HomeFooter from "../feature-plugins/home/footer"
|
||||
import HomeTips from "../feature-plugins/home/tips"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
@@ -14,6 +15,7 @@ export type InternalTuiPlugin = TuiPluginModule & {
|
||||
}
|
||||
|
||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||
import { addTheme, hasTheme } from "../context/theme"
|
||||
import { hasTheme, upsertTheme } from "../context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -49,7 +49,8 @@ type PluginLoad = {
|
||||
source: PluginSource | "internal"
|
||||
id: string
|
||||
module: TuiPluginModule
|
||||
install_theme: TuiTheme["install"]
|
||||
theme_meta: TuiConfig.PluginMeta
|
||||
theme_root: string
|
||||
}
|
||||
|
||||
type Api = HostPluginApi
|
||||
@@ -64,6 +65,7 @@ type PluginEntry = {
|
||||
id: string
|
||||
load: PluginLoad
|
||||
meta: TuiPluginMeta
|
||||
themes: Record<string, PluginMeta.Theme>
|
||||
plugin: TuiPlugin
|
||||
options: Config.PluginOptions | undefined
|
||||
enabled: boolean
|
||||
@@ -143,12 +145,54 @@ function resolveRoot(root: string) {
|
||||
return path.resolve(process.cwd(), root)
|
||||
}
|
||||
|
||||
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||
function createThemeInstaller(
|
||||
meta: TuiConfig.PluginMeta,
|
||||
root: string,
|
||||
spec: string,
|
||||
plugin: PluginEntry,
|
||||
): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||
const theme = path.basename(src, path.extname(src))
|
||||
if (hasTheme(theme)) return
|
||||
const name = path.basename(src, path.extname(src))
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${name}.json`)
|
||||
const stat = await Filesystem.statAsync(src)
|
||||
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
|
||||
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
|
||||
const exists = hasTheme(name)
|
||||
const prev = plugin.themes[name]
|
||||
|
||||
if (exists) {
|
||||
if (plugin.meta.state !== "updated") return
|
||||
if (!prev) {
|
||||
if (await Filesystem.exists(dest)) {
|
||||
plugin.themes[name] = {
|
||||
src,
|
||||
dest,
|
||||
mtime,
|
||||
size,
|
||||
}
|
||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
||||
log.warn("failed to track tui plugin theme", {
|
||||
path: spec,
|
||||
id: plugin.id,
|
||||
theme: src,
|
||||
dest,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (prev.dest !== dest) return
|
||||
if (prev.mtime === mtime && prev.size === size) return
|
||||
}
|
||||
|
||||
const text = await Filesystem.readText(src).catch((error) => {
|
||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||
@@ -170,20 +214,28 @@ function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: st
|
||||
return
|
||||
}
|
||||
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
if (exists || !(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text).catch((error) => {
|
||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||
})
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
upsertTheme(name, data)
|
||||
plugin.themes[name] = {
|
||||
src,
|
||||
dest,
|
||||
mtime,
|
||||
size,
|
||||
}
|
||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
||||
log.warn("failed to track tui plugin theme", {
|
||||
path: spec,
|
||||
id: plugin.id,
|
||||
theme: src,
|
||||
dest,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +274,6 @@ async function loadExternalPlugin(
|
||||
}
|
||||
|
||||
const root = resolveRoot(source === "file" ? spec : target)
|
||||
const install_theme = createThemeInstaller(meta, root, spec)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
|
||||
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
|
||||
return
|
||||
@@ -253,7 +304,8 @@ async function loadExternalPlugin(
|
||||
source,
|
||||
id,
|
||||
module: mod,
|
||||
install_theme,
|
||||
theme_meta: meta,
|
||||
theme_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,14 +349,11 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||
source: "internal",
|
||||
id: item.id,
|
||||
module: item,
|
||||
install_theme: createThemeInstaller(
|
||||
{
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
process.cwd(),
|
||||
spec,
|
||||
),
|
||||
theme_meta: {
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
theme_root: process.cwd(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +485,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
||||
if (plugin.scope) return true
|
||||
|
||||
const scope = createPluginScope(plugin.load, plugin.id)
|
||||
const api = pluginApi(state, plugin.load, scope, plugin.id)
|
||||
const api = pluginApi(state, plugin, scope, plugin.id)
|
||||
const ok = await Promise.resolve()
|
||||
.then(async () => {
|
||||
await plugin.plugin(api, plugin.options, plugin.meta)
|
||||
@@ -479,9 +528,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
|
||||
return deactivatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
|
||||
function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
|
||||
const api = runtime.api
|
||||
const host = runtime.slots
|
||||
const load = plugin.load
|
||||
const command: TuiPluginApi["command"] = {
|
||||
register(cb) {
|
||||
return scope.track(api.command.register(cb))
|
||||
@@ -504,7 +554,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
|
||||
}
|
||||
|
||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||
install: load.install_theme,
|
||||
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
|
||||
})
|
||||
|
||||
const event: TuiPluginApi["event"] = {
|
||||
@@ -563,13 +613,14 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
|
||||
}
|
||||
}
|
||||
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record<string, PluginMeta.Theme> = {}) {
|
||||
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
return [
|
||||
{
|
||||
id: load.id,
|
||||
load,
|
||||
meta,
|
||||
themes,
|
||||
plugin: load.module.tui,
|
||||
options,
|
||||
enabled: true,
|
||||
@@ -661,7 +712,8 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
||||
}
|
||||
|
||||
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, row)) {
|
||||
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
|
||||
for (const plugin of collectPluginEntries(entry, row, themes)) {
|
||||
if (!addPluginEntry(state, plugin)) {
|
||||
ok = false
|
||||
continue
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useDirectory } from "../context/directory"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "../plugin"
|
||||
|
||||
@@ -22,37 +18,8 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
|
||||
const connectedMcpCount = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||
})
|
||||
|
||||
const Hint = (
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef | undefined
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
@@ -81,7 +48,6 @@ export function Home() {
|
||||
},
|
||||
),
|
||||
)
|
||||
const directory = useDirectory()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,7 +67,6 @@ export function Home() {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
hint={Hint}
|
||||
workspaceID={route.workspaceID}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
@@ -111,28 +76,8 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{connectedMcpCount()} MCP
|
||||
</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.textMuted}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
|
||||
@@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.defaultFilename}
|
||||
placeholder="Enter filename"
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.text}
|
||||
|
||||
@@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={props.busy ? theme.textMuted : theme.text}
|
||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||
|
||||
@@ -260,6 +260,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -292,7 +292,7 @@ export namespace Plugin {
|
||||
for (const hook of state.hooks) {
|
||||
const fn = hook[name] as any
|
||||
if (!fn) continue
|
||||
yield* Effect.promise(() => fn(input, output))
|
||||
yield* Effect.promise(async () => fn(input, output))
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
@@ -94,6 +94,13 @@ function pluginSpec(item: unknown) {
|
||||
return item[0]
|
||||
}
|
||||
|
||||
function pluginList(data: unknown) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return
|
||||
const item = data as { plugin?: unknown }
|
||||
if (!Array.isArray(item.plugin)) return
|
||||
return item.plugin
|
||||
}
|
||||
|
||||
function parseTarget(item: unknown): Target | undefined {
|
||||
if (item === "server" || item === "tui") return { kind: item }
|
||||
if (!Array.isArray(item)) return
|
||||
@@ -118,9 +125,28 @@ function parseTargets(raw: unknown) {
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
|
||||
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, path, value, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
isArrayInsertion: insert,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function patchPluginList(
|
||||
text: string,
|
||||
list: unknown[] | undefined,
|
||||
spec: string,
|
||||
next: unknown,
|
||||
force = false,
|
||||
): { mode: Mode; text: string } {
|
||||
const pkg = parsePluginSpecifier(spec).pkg
|
||||
const rows = list.map((item, i) => ({
|
||||
const rows = (list ?? []).map((item, i) => ({
|
||||
item,
|
||||
i,
|
||||
spec: pluginSpec(item),
|
||||
@@ -133,16 +159,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
||||
})
|
||||
|
||||
if (!dup.length) {
|
||||
if (!list) {
|
||||
return {
|
||||
mode: "add",
|
||||
text: patch(text, ["plugin"], [next]),
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: "add",
|
||||
list: [...list, next],
|
||||
text: patch(text, ["plugin", list.length], next, true),
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,29 +182,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
||||
if (!keep) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
if (dup.length === 1 && keep.spec === spec) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = new Set(dup.map((item) => item.i))
|
||||
let out = text
|
||||
if (typeof keep.item === "string") {
|
||||
out = patch(out, ["plugin", keep.i], next)
|
||||
}
|
||||
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
|
||||
out = patch(out, ["plugin", keep.i, 0], spec)
|
||||
}
|
||||
|
||||
const del = dup
|
||||
.map((item) => item.i)
|
||||
.filter((i) => i !== keep.i)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
for (const i of del) {
|
||||
out = patch(out, ["plugin", i], undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "replace",
|
||||
list: rows.flatMap((row) => {
|
||||
if (!idx.has(row.i)) return [row.item]
|
||||
if (row.i !== keep.i) return []
|
||||
if (typeof row.item === "string") return [next]
|
||||
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
|
||||
return [[spec, ...row.item.slice(1)]]
|
||||
}
|
||||
return [row.item]
|
||||
}),
|
||||
text: out,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
||||
}
|
||||
}
|
||||
|
||||
const list: unknown[] =
|
||||
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
|
||||
const list = pluginList(data)
|
||||
const item = target.opts ? [spec, target.opts] : spec
|
||||
const out = patchPluginList(list, spec, item, force)
|
||||
const out = patchPluginList(text, list, spec, item, force)
|
||||
if (out.mode === "noop") {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
||||
}
|
||||
}
|
||||
|
||||
const edits = modify(text, ["plugin"], out.list, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
})
|
||||
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
|
||||
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
|
||||
if (write instanceof Error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
|
||||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
|
||||
export type Theme = {
|
||||
src: string
|
||||
dest: string
|
||||
mtime?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type Entry = {
|
||||
id: string
|
||||
source: Source
|
||||
@@ -24,6 +31,7 @@ export namespace PluginMeta {
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
themes?: Record<string, Theme>
|
||||
}
|
||||
|
||||
export type State = "first" | "updated" | "same"
|
||||
@@ -35,7 +43,7 @@ export namespace PluginMeta {
|
||||
}
|
||||
|
||||
type Store = Record<string, Entry>
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
|
||||
type Row = Touch & { core: Core }
|
||||
|
||||
function storePath() {
|
||||
@@ -52,11 +60,11 @@ export namespace PluginMeta {
|
||||
return
|
||||
}
|
||||
|
||||
function modifiedAt(file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
async function modifiedAt(file: string) {
|
||||
const stat = await Filesystem.statAsync(file)
|
||||
if (!stat) return
|
||||
const value = stat.mtimeMs
|
||||
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||
const mtime = stat.mtimeMs
|
||||
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
|
||||
}
|
||||
|
||||
function resolvedTarget(target: string) {
|
||||
@@ -66,7 +74,7 @@ export namespace PluginMeta {
|
||||
|
||||
async function npmVersion(target: string) {
|
||||
const resolved = resolvedTarget(target)
|
||||
const stat = Filesystem.stat(resolved)
|
||||
const stat = await Filesystem.statAsync(resolved)
|
||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||
.then((item) => item.version)
|
||||
@@ -84,7 +92,7 @@ export namespace PluginMeta {
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
modified: file ? modifiedAt(file) : undefined,
|
||||
modified: file ? await modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +130,7 @@ export namespace PluginMeta {
|
||||
time_changed: prev?.time_changed ?? now,
|
||||
load_count: (prev?.load_count ?? 0) + 1,
|
||||
fingerprint: fingerprint(core),
|
||||
themes: prev?.themes,
|
||||
}
|
||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||
if (state === "updated") entry.time_changed = now
|
||||
@@ -158,6 +167,20 @@ export namespace PluginMeta {
|
||||
})
|
||||
}
|
||||
|
||||
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
|
||||
const file = storePath()
|
||||
await Flock.withLock(lock(file), async () => {
|
||||
const store = await read(file)
|
||||
const entry = store[id]
|
||||
if (!entry) return
|
||||
entry.themes = {
|
||||
...(entry.themes ?? {}),
|
||||
[name]: theme,
|
||||
}
|
||||
await Filesystem.writeJson(file, store)
|
||||
})
|
||||
}
|
||||
|
||||
export async function list(): Promise<Store> {
|
||||
const file = storePath()
|
||||
return Flock.withLock(lock(file), async () => read(file))
|
||||
|
||||
@@ -176,7 +176,7 @@ export namespace Pty {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
const args = input.args || []
|
||||
if (command.endsWith("sh")) {
|
||||
if (Shell.login(command)) {
|
||||
args.push("-l")
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export namespace SessionCompaction {
|
||||
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
|
||||
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
|
||||
The summary that you construct will be used so that another agent can read it and continue the work.
|
||||
Do not call any tools. Respond only with the summary text.
|
||||
|
||||
When constructing the summary, try to stick to this template:
|
||||
---
|
||||
|
||||
@@ -32,7 +32,6 @@ import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -265,27 +264,12 @@ export namespace Session {
|
||||
0) as number,
|
||||
)
|
||||
|
||||
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
|
||||
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
|
||||
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
|
||||
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
|
||||
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
|
||||
const adjustedInputTokens = safe(
|
||||
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
|
||||
)
|
||||
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
|
||||
// (including Anthropic/Bedrock which previously excluded them). Always subtract cache
|
||||
// tokens to get the non-cached input count for separate cost calculation.
|
||||
const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
|
||||
|
||||
const total = iife(() => {
|
||||
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
|
||||
// don't compute from components
|
||||
if (
|
||||
input.model.api.npm === "@ai-sdk/anthropic" ||
|
||||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
|
||||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
|
||||
) {
|
||||
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
|
||||
}
|
||||
return input.usage.totalTokens
|
||||
})
|
||||
const total = input.usage.totalTokens
|
||||
|
||||
const tokens = {
|
||||
total,
|
||||
|
||||
@@ -13,7 +13,7 @@ const log = Log.create({ service: "instruction" })
|
||||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
|
||||
@@ -199,11 +199,19 @@ export namespace LLM {
|
||||
input.model.providerID.toLowerCase().includes("litellm") ||
|
||||
input.model.api.id.toLowerCase().includes("litellm")
|
||||
|
||||
// LiteLLM/Bedrock rejects requests where the message history contains tool
|
||||
// calls but no tools param is present. When there are no active tools (e.g.
|
||||
// during compaction), inject a stub tool to satisfy the validation requirement.
|
||||
// The stub description explicitly tells the model not to call it.
|
||||
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
|
||||
tools["_noop"] = tool({
|
||||
description:
|
||||
"Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
|
||||
inputSchema: jsonSchema({ type: "object", properties: {} }),
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string", description: "Unused" },
|
||||
},
|
||||
}),
|
||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1648,9 +1648,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
await Session.updatePart(part)
|
||||
const shell = Shell.preferred()
|
||||
const shellName = (
|
||||
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
|
||||
).toLowerCase()
|
||||
const shellName = Shell.name(shell)
|
||||
|
||||
const invocations: Record<string, { args: string[] }> = {
|
||||
nu: {
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.
|
||||
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.
|
||||
|
||||
## Values
|
||||
You are guided by these core values:
|
||||
- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
|
||||
- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
|
||||
- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
|
||||
|
||||
## Interaction Style
|
||||
You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
|
||||
|
||||
You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.
|
||||
|
||||
## Escalation
|
||||
You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
|
||||
|
||||
|
||||
# General
|
||||
As an expert coding agent, your primary focus is writing code, answering questions, and helping the user complete their task in the current environment. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
|
||||
- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`)
|
||||
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.
|
||||
|
||||
## Editing Approach
|
||||
|
||||
- The best changes are often the smallest correct changes.
|
||||
- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.
|
||||
|
||||
## Autonomy and persistence
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
|
||||
|
||||
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
|
||||
|
||||
If you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
@@ -41,13 +38,11 @@ As an expert coding agent, your primary focus is writing code, answering questio
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
## Autonomy and persistence
|
||||
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
|
||||
If the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
|
||||
If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
|
||||
## Frontend tasks
|
||||
|
||||
@@ -60,57 +55,53 @@ Exception: If working within an existing website or design system, preserve the
|
||||
|
||||
# Working with the user
|
||||
|
||||
You interact with the user through a terminal. You have 2 ways of communicating with the users:
|
||||
- Share intermediary updates in `commentary` channel.
|
||||
- After you have completed all your work, send a message to the `final` channel.
|
||||
You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.
|
||||
## General
|
||||
|
||||
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question, ") or framing phrases.
|
||||
|
||||
Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.
|
||||
|
||||
Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
|
||||
|
||||
## Formatting rules
|
||||
|
||||
- You may format with GitHub-flavored Markdown.
|
||||
- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
|
||||
- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
|
||||
- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use markdown links (not inline code) for clickable file paths.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
- Don’t use emojis or em dashes unless explicitly instructed.
|
||||
Your responses are rendered as GitHub-flavored Markdown.
|
||||
|
||||
## Final answer instructions
|
||||
Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
|
||||
|
||||
Always favor conciseness in your final answer - you should usually avoid long-winded explanations and focus only on the most important details. For casual chit-chat, just chat. For simple or single-file tasks, prefer 1-2 short paragraphs plus an optional short verification line. Do not default to bullets. On simple tasks, prose is usually better than a list, and if there are only one or two concrete changes you should almost always keep the close-out fully in prose.
|
||||
Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
|
||||
On larger tasks, use at most 2-3 high-level sections when helpful. Each section can be a short paragraph or a few flat bullets. Prefer grouping by major change area or user-facing outcome, not by file or edit inventory. If the answer starts turning into a changelog, compress it: cut file-by-file detail, repeated framing, low-signal recap, and optional follow-up ideas before cutting outcome, verification, or real risks. Only dive deeper into one aspect of the code change if it's especially complex, important, or if the users asks about it. This also holds true for PR explanations, codebase walkthroughs, or architectural decisions: provide a high-level walkthrough unless specifically asked and cap answers at 2-3 sections.
|
||||
Use inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.
|
||||
|
||||
Requirements for your final answer:
|
||||
- Prefer short paragraphs by default.
|
||||
- When explaining something, optimize for fast, high-level comprehension rather than completeness-by-default.
|
||||
- Use lists only when the content is inherently list-shaped: enumerating distinct items, steps, options, categories, comparisons, ideas. Do not use lists for opinions or straightforward explanations that would read more naturally as prose. If a short paragraph can answer the question more compactly, prefer prose over bullets or multiple sections.
|
||||
- Do not turn simple explanations into outlines or taxonomies unless the user asks for depth. If a list is used, each bullet should be a complete standalone point.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”, "You're right to call that out") or framing phrases.
|
||||
- When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
- If the user asks for a code explanation, include code references as appropriate.
|
||||
- If you weren't able to do something, for example run tests, tell the user.
|
||||
- Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.
|
||||
Code samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.
|
||||
|
||||
## Intermediary updates
|
||||
Don’t use emojis or em dashes unless explicitly instructed.
|
||||
|
||||
- Intermediary updates go to the `commentary` channel.
|
||||
- User updates are short updates while you are working, they are NOT final answers.
|
||||
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
|
||||
- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
|
||||
- You provide user updates frequently, every 30s.
|
||||
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
|
||||
- When working for a while, keep updates informative and varied, but stay concise.
|
||||
- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
|
||||
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
|
||||
- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.
|
||||
- Tone of your updates MUST match your personality.
|
||||
## Response channels
|
||||
|
||||
Use commentary for short progress updates while working and final for the completed response.
|
||||
|
||||
### `commentary` channel
|
||||
|
||||
Only use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.
|
||||
|
||||
Send updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.
|
||||
|
||||
Do not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.
|
||||
|
||||
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question") or framing phrases.
|
||||
|
||||
Before substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.
|
||||
|
||||
After you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
|
||||
|
||||
### `final` channel
|
||||
|
||||
Use final for the completed response.
|
||||
|
||||
Structure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
|
||||
|
||||
If the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.
|
||||
|
||||
For large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldn’t be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.
|
||||
|
||||
@@ -9,6 +9,10 @@ import { setTimeout as sleep } from "node:timers/promises"
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
|
||||
export namespace Shell {
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
||||
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
@@ -39,18 +43,46 @@ export namespace Shell {
|
||||
}
|
||||
}
|
||||
}
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
}
|
||||
return Bun.which(shell) || shell
|
||||
}
|
||||
|
||||
function pick() {
|
||||
const pwsh = Bun.which("pwsh")
|
||||
if (pwsh) return pwsh
|
||||
const powershell = Bun.which("powershell")
|
||||
if (powershell) return powershell
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
||||
if (process.platform === "win32") {
|
||||
const shell = pick()
|
||||
if (shell) return shell
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash() {
|
||||
if (process.platform !== "win32") return
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
|
||||
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
|
||||
const bash = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(bash)?.size) return bash
|
||||
}
|
||||
const file = gitbash()
|
||||
if (file) return file
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
@@ -59,15 +91,20 @@ export namespace Shell {
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => {
|
||||
const s = process.env.SHELL
|
||||
if (s) return s
|
||||
return fallback()
|
||||
})
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
export const acceptable = lazy(() => {
|
||||
const s = process.env.SHELL
|
||||
if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s
|
||||
return fallback()
|
||||
})
|
||||
export function login(file: string) {
|
||||
return LOGIN.has(name(file))
|
||||
}
|
||||
|
||||
export function posix(file: string) {
|
||||
return POSIX.has(name(file))
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => select(process.env.SHELL))
|
||||
|
||||
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import os from "os"
|
||||
import { spawn } from "child_process"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
@@ -6,12 +7,12 @@ import DESCRIPTION from "./bash.txt"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Language } from "web-tree-sitter"
|
||||
import fs from "fs/promises"
|
||||
import { Language, type Node } from "web-tree-sitter"
|
||||
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Flag } from "@/flag/flag.ts"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Shell } from "@/shell/shell"
|
||||
|
||||
import { BashArity } from "@/permission/arity"
|
||||
@@ -20,6 +21,43 @@ import { Plugin } from "@/plugin"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
||||
const PS = new Set(["powershell", "pwsh"])
|
||||
const CWD = new Set(["cd", "push-location", "set-location"])
|
||||
const FILES = new Set([
|
||||
...CWD,
|
||||
"rm",
|
||||
"cp",
|
||||
"mv",
|
||||
"mkdir",
|
||||
"touch",
|
||||
"chmod",
|
||||
"chown",
|
||||
"cat",
|
||||
// Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
|
||||
// already hit the entries above, and alias normalization should happen in one
|
||||
// place later so we do not risk double-prompting.
|
||||
"get-content",
|
||||
"set-content",
|
||||
"add-content",
|
||||
"copy-item",
|
||||
"move-item",
|
||||
"remove-item",
|
||||
"new-item",
|
||||
"rename-item",
|
||||
])
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
type Part = {
|
||||
type: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type Scan = {
|
||||
dirs: Set<string>
|
||||
patterns: Set<string>
|
||||
always: Set<string>
|
||||
}
|
||||
|
||||
export const log = Log.create({ service: "bash-tool" })
|
||||
|
||||
@@ -30,6 +68,350 @@ const resolveWasm = (asset: string) => {
|
||||
return fileURLToPath(url)
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
const out: Part[] = []
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i)
|
||||
if (!child) continue
|
||||
if (child.type === "command_elements") {
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const item = child.child(j)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
|
||||
out.push({ type: item.type, text: item.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
child.type !== "command_name" &&
|
||||
child.type !== "command_name_expr" &&
|
||||
child.type !== "word" &&
|
||||
child.type !== "string" &&
|
||||
child.type !== "raw_string" &&
|
||||
child.type !== "concatenation"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
out.push({ type: child.type, text: child.text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function source(node: Node) {
|
||||
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
|
||||
}
|
||||
|
||||
function commands(node: Node) {
|
||||
return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
|
||||
}
|
||||
|
||||
function unquote(text: string) {
|
||||
if (text.length < 2) return text
|
||||
const first = text[0]
|
||||
const last = text[text.length - 1]
|
||||
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
|
||||
return text
|
||||
}
|
||||
|
||||
function home(text: string) {
|
||||
if (text === "~") return os.homedir()
|
||||
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
|
||||
return text
|
||||
}
|
||||
|
||||
function envValue(key: string) {
|
||||
if (process.platform !== "win32") return process.env[key]
|
||||
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
||||
return name ? process.env[name] : undefined
|
||||
}
|
||||
|
||||
function auto(key: string, cwd: string, shell: string) {
|
||||
const name = key.toUpperCase()
|
||||
if (name === "HOME") return os.homedir()
|
||||
if (name === "PWD") return cwd
|
||||
if (name === "PSHOME") return path.dirname(shell)
|
||||
}
|
||||
|
||||
function expand(text: string, cwd: string, shell: string) {
|
||||
const out = unquote(text)
|
||||
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")
|
||||
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")
|
||||
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")
|
||||
return home(out)
|
||||
}
|
||||
|
||||
function provider(text: string) {
|
||||
const match = text.match(/^([A-Za-z]+)::(.*)$/)
|
||||
if (match) {
|
||||
if (match[1].toLowerCase() !== "filesystem") return
|
||||
return match[2]
|
||||
}
|
||||
const prefix = text.match(/^([A-Za-z]+):(.*)$/)
|
||||
if (!prefix) return text
|
||||
if (prefix[1].length === 1) return text
|
||||
return
|
||||
}
|
||||
|
||||
function dynamic(text: string, ps: boolean) {
|
||||
if (text.startsWith("(") || text.startsWith("@(")) return true
|
||||
if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
|
||||
if (ps) return /\$(?!env:)/i.test(text)
|
||||
return text.includes("$")
|
||||
}
|
||||
|
||||
function prefix(text: string) {
|
||||
const match = /[?*\[]/.exec(text)
|
||||
if (!match) return text
|
||||
if (match.index === 0) return
|
||||
return text.slice(0, match.index)
|
||||
}
|
||||
|
||||
async function cygpath(shell: string, text: string) {
|
||||
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
|
||||
if (out.code !== 0) return
|
||||
const file = out.text.trim()
|
||||
if (!file) return
|
||||
return Filesystem.normalizePath(file)
|
||||
}
|
||||
|
||||
async function resolvePath(text: string, root: string, shell: string) {
|
||||
if (process.platform === "win32") {
|
||||
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
|
||||
const file = await cygpath(shell, text)
|
||||
if (file) return file
|
||||
}
|
||||
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
|
||||
}
|
||||
return path.resolve(root, text)
|
||||
}
|
||||
|
||||
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
|
||||
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
|
||||
const file = text && prefix(text)
|
||||
if (!file || dynamic(file, ps)) return
|
||||
const next = ps ? provider(file) : file
|
||||
if (!next) return
|
||||
return resolvePath(next, cwd, shell)
|
||||
}
|
||||
|
||||
function pathArgs(list: Part[], ps: boolean) {
|
||||
if (!ps) {
|
||||
return list
|
||||
.slice(1)
|
||||
.filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+")))
|
||||
.map((item) => item.text)
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let want = false
|
||||
for (const item of list.slice(1)) {
|
||||
if (want) {
|
||||
out.push(item.text)
|
||||
want = false
|
||||
continue
|
||||
}
|
||||
if (item.type === "command_parameter") {
|
||||
const flag = item.text.toLowerCase()
|
||||
if (SWITCHES.has(flag)) continue
|
||||
want = FLAGS.has(flag)
|
||||
continue
|
||||
}
|
||||
out.push(item.text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
|
||||
const scan: Scan = {
|
||||
dirs: new Set<string>(),
|
||||
patterns: new Set<string>(),
|
||||
always: new Set<string>(),
|
||||
}
|
||||
|
||||
for (const node of commands(root)) {
|
||||
const command = parts(node)
|
||||
const tokens = command.map((item) => item.text)
|
||||
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
|
||||
|
||||
if (cmd && FILES.has(cmd)) {
|
||||
for (const arg of pathArgs(command, ps)) {
|
||||
const resolved = await argPath(arg, cwd, ps, shell)
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (!resolved || Instance.containsPath(resolved)) continue
|
||||
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
|
||||
scan.dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.length && (!cmd || !CWD.has(cmd))) {
|
||||
scan.patterns.add(source(node))
|
||||
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
|
||||
}
|
||||
}
|
||||
|
||||
return scan
|
||||
}
|
||||
|
||||
function preview(text: string) {
|
||||
if (text.length <= MAX_METADATA_LENGTH) return text
|
||||
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
|
||||
}
|
||||
|
||||
async function parse(command: string, ps: boolean) {
|
||||
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
|
||||
if (!tree) throw new Error("Failed to parse command")
|
||||
return tree.rootNode
|
||||
}
|
||||
|
||||
async function ask(ctx: Tool.Context, scan: Scan) {
|
||||
if (scan.dirs.size > 0) {
|
||||
const globs = Array.from(scan.dirs).map((dir) => {
|
||||
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (scan.patterns.size === 0) return
|
||||
await ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
async function shellEnv(ctx: Tool.Context, cwd: string) {
|
||||
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
|
||||
return {
|
||||
...process.env,
|
||||
...extra.env,
|
||||
}
|
||||
}
|
||||
|
||||
function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
if (process.platform === "win32" && PS.has(name)) {
|
||||
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
})
|
||||
}
|
||||
|
||||
return spawn(command, {
|
||||
shell,
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeout: number
|
||||
description: string
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
|
||||
let output = ""
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
|
||||
const append = (chunk: Buffer) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", append)
|
||||
proc.stderr?.on("data", append)
|
||||
|
||||
let expired = false
|
||||
let aborted = false
|
||||
let exited = false
|
||||
|
||||
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
||||
|
||||
if (ctx.abort.aborted) {
|
||||
aborted = true
|
||||
await kill()
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
void kill()
|
||||
}
|
||||
|
||||
ctx.abort.addEventListener("abort", abort, { once: true })
|
||||
const timer = setTimeout(() => {
|
||||
expired = true
|
||||
void kill()
|
||||
}, input.timeout + 100)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
ctx.abort.removeEventListener("abort", abort)
|
||||
}
|
||||
|
||||
proc.once("exit", () => {
|
||||
exited = true
|
||||
})
|
||||
|
||||
proc.once("close", () => {
|
||||
exited = true
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
|
||||
proc.once("error", (error) => {
|
||||
exited = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
const metadata: string[] = []
|
||||
if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (aborted) metadata.push("User aborted the command")
|
||||
if (metadata.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.description,
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
exit: proc.exitCode,
|
||||
description: input.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||
@@ -44,23 +426,36 @@ const parser = lazy(async () => {
|
||||
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const bashPath = resolveWasm(bashWasm)
|
||||
const bashLanguage = await Language.load(bashPath)
|
||||
const p = new Parser()
|
||||
p.setLanguage(bashLanguage)
|
||||
return p
|
||||
const psPath = resolveWasm(psWasm)
|
||||
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.define("bash", async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${maxLines}", String(Truncate.MAX_LINES)).replaceAll(
|
||||
"${maxBytes}",
|
||||
String(Truncate.MAX_BYTES),
|
||||
),
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
|
||||
@@ -77,195 +472,29 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir || Instance.directory
|
||||
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
if (!tree) {
|
||||
throw new Error("Failed to parse command")
|
||||
}
|
||||
const directories = new Set<string>()
|
||||
if (!Instance.containsPath(cwd)) directories.add(cwd)
|
||||
const patterns = new Set<string>()
|
||||
const always = new Set<string>()
|
||||
const ps = PS.has(name)
|
||||
const root = await parse(params.command, ps)
|
||||
const scan = await collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
await ask(ctx, scan)
|
||||
|
||||
for (const node of tree.rootNode.descendantsOfType("command")) {
|
||||
if (!node) continue
|
||||
|
||||
// Get full command text including redirects if present
|
||||
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
|
||||
|
||||
const command = []
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i)
|
||||
if (!child) continue
|
||||
if (
|
||||
child.type !== "command_name" &&
|
||||
child.type !== "word" &&
|
||||
child.type !== "string" &&
|
||||
child.type !== "raw_string" &&
|
||||
child.type !== "concatenation"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
command.push(child.text)
|
||||
}
|
||||
|
||||
// not an exhaustive list, but covers most common cases
|
||||
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
|
||||
for (const arg of command.slice(1)) {
|
||||
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
|
||||
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (resolved) {
|
||||
const normalized =
|
||||
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
|
||||
if (!Instance.containsPath(normalized)) {
|
||||
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
|
||||
directories.add(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cd covered by above check
|
||||
if (command.length && command[0] !== "cd") {
|
||||
patterns.add(commandText)
|
||||
always.add(BashArity.prefix(command).join(" ") + " *")
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.size > 0) {
|
||||
const globs = Array.from(directories).map((dir) => {
|
||||
// Preserve POSIX-looking paths with /s, even on Windows
|
||||
if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (patterns.size > 0) {
|
||||
await ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(patterns),
|
||||
always: Array.from(always),
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
const shellEnv = await Plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ env: {} },
|
||||
return run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: await shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
const proc = spawn(params.command, {
|
||||
shell,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
let output = ""
|
||||
|
||||
// Initialize metadata with empty output
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
|
||||
const append = (chunk: Buffer) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
|
||||
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", append)
|
||||
proc.stderr?.on("data", append)
|
||||
|
||||
let timedOut = false
|
||||
let aborted = false
|
||||
let exited = false
|
||||
|
||||
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
||||
|
||||
if (ctx.abort.aborted) {
|
||||
aborted = true
|
||||
await kill()
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
aborted = true
|
||||
void kill()
|
||||
}
|
||||
|
||||
ctx.abort.addEventListener("abort", abortHandler, { once: true })
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
void kill()
|
||||
}, timeout + 100)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutTimer)
|
||||
ctx.abort.removeEventListener("abort", abortHandler)
|
||||
}
|
||||
|
||||
proc.once("exit", () => {
|
||||
exited = true
|
||||
cleanup()
|
||||
resolve()
|
||||
})
|
||||
|
||||
proc.once("error", (error) => {
|
||||
exited = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
const resultMetadata: string[] = []
|
||||
|
||||
if (timedOut) {
|
||||
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
resultMetadata.push("User aborted the command")
|
||||
}
|
||||
|
||||
if (resultMetadata.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
|
||||
exit: proc.exitCode,
|
||||
description: params.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
All commands run in current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
|
||||
Be aware: OS: ${os}, Shell: ${shell}
|
||||
|
||||
All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
@@ -35,7 +37,7 @@ Usage notes:
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- ${chaining}
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID using `cd <directory> && <command>`. Use the `workdir` parameter to change directories instead.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import type { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
type Kind = "file" | "directory"
|
||||
|
||||
@@ -14,19 +15,23 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
|
||||
|
||||
if (options?.bypass) return
|
||||
|
||||
if (Instance.containsPath(target)) return
|
||||
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
|
||||
if (Instance.containsPath(full)) return
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const parentDir = kind === "directory" ? target : path.dirname(target)
|
||||
const glob = path.join(parentDir, "*").replaceAll("\\", "/")
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
const glob =
|
||||
process.platform === "win32"
|
||||
? Filesystem.normalizePathPattern(path.join(dir, "*"))
|
||||
: path.join(dir, "*").replaceAll("\\", "/")
|
||||
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: [glob],
|
||||
always: [glob],
|
||||
metadata: {
|
||||
filepath: target,
|
||||
parentDir,
|
||||
filepath: full,
|
||||
parentDir: dir,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export const ReadTool = Tool.define("read", {
|
||||
if (!path.isAbsolute(filepath)) {
|
||||
filepath = path.resolve(Instance.directory, filepath)
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
filepath = Filesystem.normalizePath(filepath)
|
||||
}
|
||||
const title = path.relative(Instance.worktree, filepath)
|
||||
|
||||
const stat = Filesystem.stat(filepath)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "./glob"
|
||||
@@ -25,6 +25,13 @@ export namespace Filesystem {
|
||||
return statSync(p, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
|
||||
return statFile(p).catch((e) => {
|
||||
if (isEnoent(e)) return undefined
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
export async function size(p: string): Promise<number> {
|
||||
const s = stat(p)?.size ?? 0
|
||||
return typeof s === "bigint" ? Number(s) : s
|
||||
@@ -106,13 +113,23 @@ export namespace Filesystem {
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
|
||||
try {
|
||||
return realpathSync.native(p)
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return p
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePathPattern(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
if (p === "*") return p
|
||||
const match = p.match(/^(.*)[\\/]\*$/)
|
||||
if (!match) return normalizePath(p)
|
||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||
return join(normalizePath(dir), "*")
|
||||
}
|
||||
|
||||
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
|
||||
// Also resolves symlinks so that callers using the result as a cache key
|
||||
// always get the same canonical path for a given physical directory.
|
||||
|
||||
@@ -561,3 +561,106 @@ describe("tui.plugin.loader", () => {
|
||||
expect(data.leaked_global_to_local).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("updates installed theme when plugin metadata changes", async () => {
|
||||
await using tmp = await tmpdir<{
|
||||
spec: string
|
||||
pluginPath: string
|
||||
themePath: string
|
||||
dest: string
|
||||
themeName: string
|
||||
}>({
|
||||
init: async (dir) => {
|
||||
const pluginPath = path.join(dir, "theme-update-plugin.ts")
|
||||
const spec = pathToFileURL(pluginPath).href
|
||||
const themeFile = "theme-update.json"
|
||||
const themePath = path.join(dir, themeFile)
|
||||
const dest = path.join(dir, ".opencode", "themes", themeFile)
|
||||
const themeName = themeFile.replace(/\.json$/, "")
|
||||
const configPath = path.join(dir, "tui.json")
|
||||
|
||||
await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
|
||||
await Bun.write(
|
||||
pluginPath,
|
||||
`export default {
|
||||
id: "demo.theme-update",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.theme_path) return
|
||||
await api.theme.install(options.theme_path)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [[spec, { theme_path: `./${themeFile}` }]],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
spec,
|
||||
pluginPath,
|
||||
themePath,
|
||||
dest,
|
||||
themeName,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const install = spyOn(Config, "installDependencies").mockResolvedValue()
|
||||
|
||||
const api = () =>
|
||||
createTuiPluginApi({
|
||||
theme: {
|
||||
has(name) {
|
||||
return allThemes()[name] !== undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init(api())
|
||||
await TuiPluginRuntime.dispose()
|
||||
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
|
||||
|
||||
await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
|
||||
await Bun.write(
|
||||
tmp.extra.pluginPath,
|
||||
`export default {
|
||||
id: "demo.theme-update",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.theme_path) return
|
||||
await api.theme.install(options.theme_path)
|
||||
},
|
||||
}
|
||||
// v2
|
||||
`,
|
||||
)
|
||||
const stamp = new Date(Date.now() + 10_000)
|
||||
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
|
||||
await fs.utimes(tmp.extra.themePath, stamp, stamp)
|
||||
|
||||
await TuiPluginRuntime.init(api())
|
||||
const text = await fs.readFile(tmp.extra.dest, "utf8")
|
||||
expect(text).toContain("#222222")
|
||||
expect(text).not.toContain("#111111")
|
||||
const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
|
||||
process.env.OPENCODE_PLUGIN_META_FILE!,
|
||||
)
|
||||
expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
install.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { parse as parseJsonc } from "jsonc-parser"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -120,6 +121,99 @@ describe("plugin.install.task", () => {
|
||||
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
|
||||
})
|
||||
|
||||
test("preserves JSONC comments when adding plugins to server and tui config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server", "tui"])
|
||||
const cfg = path.join(tmp.path, ".opencode")
|
||||
const server = path.join(cfg, "opencode.jsonc")
|
||||
const tui = path.join(cfg, "tui.jsonc")
|
||||
await fs.mkdir(cfg, { recursive: true })
|
||||
await Bun.write(
|
||||
server,
|
||||
`{
|
||||
// server head
|
||||
"plugin": [
|
||||
// server keep
|
||||
"seed@1.0.0"
|
||||
],
|
||||
// server tail
|
||||
"model": "x"
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
tui,
|
||||
`{
|
||||
// tui head
|
||||
"plugin": [
|
||||
// tui keep
|
||||
"seed@1.0.0"
|
||||
],
|
||||
// tui tail
|
||||
"theme": "opencode"
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: "acme@1.2.3",
|
||||
},
|
||||
deps(path.join(tmp.path, "global"), target),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(tmp.path))
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const serverText = await fs.readFile(server, "utf8")
|
||||
const tuiText = await fs.readFile(tui, "utf8")
|
||||
expect(serverText).toContain("// server head")
|
||||
expect(serverText).toContain("// server keep")
|
||||
expect(serverText).toContain("// server tail")
|
||||
expect(tuiText).toContain("// tui head")
|
||||
expect(tuiText).toContain("// tui keep")
|
||||
expect(tuiText).toContain("// tui tail")
|
||||
|
||||
const serverJson = parseJsonc(serverText) as { plugin?: unknown[] }
|
||||
const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] }
|
||||
expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
|
||||
expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
|
||||
})
|
||||
|
||||
test("preserves JSONC comments when force replacing plugin version", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server"])
|
||||
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
|
||||
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||
await Bun.write(
|
||||
cfg,
|
||||
`{
|
||||
"plugin": [
|
||||
// keep this note
|
||||
"acme@1.0.0"
|
||||
]
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: "acme@2.0.0",
|
||||
force: true,
|
||||
},
|
||||
deps(path.join(tmp.path, "global"), target),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(tmp.path))
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const text = await fs.readFile(cfg, "utf8")
|
||||
expect(text).toContain("// keep this note")
|
||||
|
||||
const json = parseJsonc(text) as { plugin?: unknown[] }
|
||||
expect(json.plugin).toEqual(["acme@2.0.0"])
|
||||
})
|
||||
|
||||
test("supports resolver target pointing to a file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server"])
|
||||
|
||||
111
packages/opencode/test/plugin/trigger.test.ts
Normal file
111
packages/opencode/test/plugin/trigger.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { afterAll, afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
return
|
||||
}
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
})
|
||||
|
||||
async function project(source: string) {
|
||||
return tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
await Bun.write(file, source)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(file).href],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("plugin.trigger", () => {
|
||||
test("runs synchronous hooks without crashing", async () => {
|
||||
await using tmp = await project(
|
||||
[
|
||||
"export default async () => ({",
|
||||
' "experimental.chat.system.transform": (_input, output) => {',
|
||||
' output.system.unshift("sync")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const out = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const out = { system: [] as string[] }
|
||||
await Plugin.trigger(
|
||||
"experimental.chat.system.transform",
|
||||
{
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
} as any,
|
||||
},
|
||||
out,
|
||||
)
|
||||
return out
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.system).toEqual(["sync"])
|
||||
})
|
||||
|
||||
test("awaits asynchronous hooks", async () => {
|
||||
await using tmp = await project(
|
||||
[
|
||||
"export default async () => ({",
|
||||
' "experimental.chat.system.transform": async (_input, output) => {',
|
||||
" await Bun.sleep(1)",
|
||||
' output.system.unshift("async")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const out = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const out = { system: [] as string[] }
|
||||
await Plugin.trigger(
|
||||
"experimental.chat.system.transform",
|
||||
{
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
} as any,
|
||||
},
|
||||
out,
|
||||
)
|
||||
return out
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.system).toEqual(["async"])
|
||||
})
|
||||
})
|
||||
59
packages/opencode/test/pty/pty-shell.test.ts
Normal file
59
packages/opencode/test/pty/pty-shell.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Shell.preferred.reset()
|
||||
|
||||
describe("pty shell args", () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const ps = Bun.which("pwsh") || Bun.which("powershell")
|
||||
if (ps) {
|
||||
test(
|
||||
"does not add login args to pwsh",
|
||||
async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: ps, title: "pwsh" })
|
||||
try {
|
||||
expect(info.args).toEqual([])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
|
||||
const bash = (() => {
|
||||
const shell = Shell.preferred()
|
||||
if (Shell.name(shell) === "bash") return shell
|
||||
return Shell.gitbash()
|
||||
})()
|
||||
if (bash) {
|
||||
test(
|
||||
"adds login args to bash",
|
||||
async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: bash, title: "bash" })
|
||||
try {
|
||||
expect(info.args).toEqual(["-l"])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -964,8 +964,9 @@ describe("session.getUsage", () => {
|
||||
expect(result.tokens.cache.write).toBe(300)
|
||||
})
|
||||
|
||||
test("does not subtract cached tokens for anthropic provider", () => {
|
||||
test("subtracts cached tokens for anthropic provider", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
||||
const result = Session.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
@@ -979,7 +980,7 @@ describe("session.getUsage", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
expect(result.tokens.input).toBe(800)
|
||||
expect(result.tokens.cache.read).toBe(200)
|
||||
})
|
||||
|
||||
@@ -1043,11 +1044,10 @@ describe("session.getUsage", () => {
|
||||
"computes total from components for %s models",
|
||||
(npm) => {
|
||||
const model = createModel({ context: 100_000, output: 32_000, npm })
|
||||
// AI SDK v6: inputTokens includes cached tokens for all providers
|
||||
const usage = {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
// These providers typically report total as input + output only,
|
||||
// excluding cache read/write.
|
||||
totalTokens: 1500,
|
||||
cachedInputTokens: 200,
|
||||
}
|
||||
@@ -1064,10 +1064,12 @@ describe("session.getUsage", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
|
||||
expect(result.tokens.input).toBe(500)
|
||||
expect(result.tokens.cache.read).toBe(200)
|
||||
expect(result.tokens.cache.write).toBe(300)
|
||||
expect(result.tokens.total).toBe(2000)
|
||||
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
|
||||
expect(result.tokens.total).toBe(1500)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1081,10 +1083,12 @@ describe("session.getUsage", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
|
||||
expect(result.tokens.input).toBe(500)
|
||||
expect(result.tokens.cache.read).toBe(200)
|
||||
expect(result.tokens.cache.write).toBe(300)
|
||||
expect(result.tokens.total).toBe(2000)
|
||||
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
|
||||
expect(result.tokens.total).toBe(1500)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
73
packages/opencode/test/shell/shell.test.ts
Normal file
73
packages/opencode/test/shell/shell.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
}
|
||||
}
|
||||
|
||||
describe("shell", () => {
|
||||
test("normalizes shell names", () => {
|
||||
expect(Shell.name("/bin/bash")).toBe("bash")
|
||||
if (process.platform === "win32") {
|
||||
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
|
||||
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
|
||||
}
|
||||
})
|
||||
|
||||
test("detects login shells", () => {
|
||||
expect(Shell.login("/bin/bash")).toBe(true)
|
||||
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
test("detects posix shells", () => {
|
||||
expect(Shell.posix("/bin/bash")).toBe(true)
|
||||
expect(Shell.posix("/bin/fish")).toBe(false)
|
||||
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves /usr/bin/bash from env to Git Bash", async () => {
|
||||
const bash = Shell.gitbash()
|
||||
if (!bash) return
|
||||
await withShell("/usr/bin/bash", async () => {
|
||||
expect(Shell.acceptable()).toBe(bash)
|
||||
expect(Shell.preferred()).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare PowerShell shells", async () => {
|
||||
const shell = Bun.which("pwsh") || Bun.which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(Shell.preferred()).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import path from "path"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
@@ -16,6 +18,9 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
@@ -66,7 +71,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/")
|
||||
const expected = glob(path.join(path.dirname(target), "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
@@ -92,7 +97,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
const expected = path.join(target, "*").replaceAll("\\", "/")
|
||||
const expected = glob(path.join(target, "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
@@ -125,4 +130,69 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "outside.txt"), "x")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const target = path.join(outerTmp.path, "outside.txt")
|
||||
const alt = target
|
||||
.replace(/^[A-Za-z]:/, "")
|
||||
.replaceAll("\\", "/")
|
||||
.toLowerCase()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, alt)
|
||||
},
|
||||
})
|
||||
|
||||
const req = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = glob(path.join(outerTmp.path, "*"))
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.patterns).toEqual([expected])
|
||||
expect(req!.always).toEqual([expected])
|
||||
})
|
||||
|
||||
test("uses drive root glob for root files", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const root = path.parse(tmp.path).root
|
||||
const target = path.join(root, "boot.ini")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, target)
|
||||
},
|
||||
})
|
||||
|
||||
const req = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = path.join(root, "*")
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.patterns).toEqual([expected])
|
||||
expect(req!.always).toEqual([expected])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,6 +25,10 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
describe("tool.read external_directory permission", () => {
|
||||
test("allows reading absolute path inside project directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -79,11 +83,44 @@ describe("tool.read external_directory permission", () => {
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path.replaceAll("\\", "/")))).toBe(true)
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes read permission paths on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "hello world")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const target = path.join(tmp.path, "test.txt")
|
||||
const alt = target
|
||||
.replace(/^[A-Za-z]:/, "")
|
||||
.replaceAll("\\", "/")
|
||||
.toLowerCase()
|
||||
await read.execute({ filePath: alt }, testCtx)
|
||||
const readReq = requests.find((r) => r.permission === "read")
|
||||
expect(readReq).toBeDefined()
|
||||
expect(readReq!.patterns).toEqual([full(target)])
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -105,7 +142,7 @@ describe("tool.read external_directory permission", () => {
|
||||
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*").replaceAll("\\", "/"))
|
||||
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -555,4 +555,13 @@ describe("filesystem", () => {
|
||||
expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizePathPattern()", () => {
|
||||
test("preserves drive root globs on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.parse(tmp.path).root
|
||||
expect(Filesystem.normalizePathPattern(path.join(root, "*"))).toBe(path.join(root, "*"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -296,6 +296,7 @@ export type TuiSlotMap = {
|
||||
workspace_id?: string
|
||||
}
|
||||
home_bottom: {}
|
||||
home_footer: {}
|
||||
sidebar_title: {
|
||||
session_id: string
|
||||
title: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.7",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -133,9 +133,7 @@ OpenCode Go حاليًا في المرحلة التجريبية (beta).
|
||||
|
||||
## الخصوصية
|
||||
|
||||
الخطة مصممة بشكل أساسي للمستخدمين الدوليين، مع نماذج مستضافة في الولايات المتحدة، والاتحاد الأوروبي، وسنغافورة من أجل وصول عالمي مستقر.
|
||||
|
||||
<a href={email}>تواصل معنا</a> إذا كان لديك أي أسئلة.
|
||||
صُمِّمت الخطة أساسًا للمستخدمين الدوليين، مع استضافة النماذج في US وEU وSingapore لضمان وصول عالمي مستقر. ويتّبع مزوّدونا سياسة عدم الاحتفاظ بالبيانات، ولا يستخدمون بياناتك في تدريب النماذج.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ koristi format `opencode-go/<model-id>`. Na primjer, za Kimi K2.5, koristili bis
|
||||
|
||||
## Privatnost
|
||||
|
||||
Plan je dizajniran prvenstveno za međunarodne korisnike, sa modelima hostovanim u SAD-u, EU i Singapuru za stabilan globalni pristup.
|
||||
|
||||
<a href={email}>Kontaktirajte nas</a> ako imate bilo kakvih pitanja.
|
||||
Plan je prvenstveno namijenjen međunarodnim korisnicima, a modeli su smješteni u US, EU i Singaporeu radi stabilnog globalnog pristupa. Naši pružaoci usluga primjenjuju politiku nultog zadržavanja podataka i ne koriste vaše podatke za treniranje modela.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ bruge `opencode-go/kimi-k2.5` i din config.
|
||||
|
||||
## Privatliv
|
||||
|
||||
Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for at sikre stabil global adgang.
|
||||
|
||||
<a href={email}>Kontakt os</a>, hvis du har spørgsmål.
|
||||
Planen er primært designet til internationale brugere med modeller hostet i US, EU og Singapore for stabil global adgang. Vores udbydere følger en zero-retention-policy og bruger ikke dine data til modeltræning.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -121,9 +121,7 @@ Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Fo
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Der Plan wurde primär für internationale Nutzer entwickelt, wobei die Modelle für einen stabilen weltweiten Zugriff in den USA, der EU und Singapur gehostet werden.
|
||||
|
||||
<a href={email}>Kontaktiere uns</a>, falls du Fragen hast.
|
||||
Der Plan ist in erster Linie für internationale Nutzer konzipiert, mit in US, EU und Singapore gehosteten Modellen für einen stabilen weltweiten Zugriff. Unsere Anbieter befolgen eine Zero-Retention-Richtlinie und verwenden Ihre Daten nicht für das Modelltraining.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ usa el formato `opencode-go/<model-id>`. Por ejemplo, para Kimi K2.5, usarías
|
||||
|
||||
## Privacidad
|
||||
|
||||
El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable.
|
||||
|
||||
<a href={email}>Contáctanos</a> si tienes alguna pregunta.
|
||||
El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en US, EU y Singapore para ofrecer un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -119,9 +119,7 @@ L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilis
|
||||
|
||||
## Confidentialité
|
||||
|
||||
Le forfait est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.
|
||||
|
||||
<a href={email}>Contactez-nous</a> si vous avez des questions.
|
||||
Cette offre est conçue avant tout pour les utilisateurs internationaux, avec des modèles hébergés aux US, dans l’EU et à Singapore afin d’assurer un accès mondial stable. Nos fournisseurs appliquent une politique de rétention zéro et n’utilisent pas vos données pour l’entraînement des modèles.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -135,8 +135,6 @@ use `opencode-go/kimi-k2.5` in your config.
|
||||
|
||||
The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.
|
||||
|
||||
<a href={email}>Contact us</a> if you have any questions.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -131,9 +131,7 @@ utilizza il formato `opencode-go/<model-id>`. Ad esempio, per Kimi K2.5, userest
|
||||
|
||||
## Privacy
|
||||
|
||||
Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, nell'Unione Europea e a Singapore per un accesso globale stabile.
|
||||
|
||||
<a href={email}>Contattaci</a> in caso di domande.
|
||||
Il piano è pensato principalmente per gli utenti internazionali, con modelli ospitati negli US, nell’EU e a Singapore per un accesso globale stabile. I nostri provider seguono una politica di zero-retention e non utilizzano i tuoi dati per l’addestramento dei modelli.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user