mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-26 02:34:19 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8337cddc4 | ||
|
|
444178e079 | ||
|
|
4551282a4b | ||
|
|
9d29d692c6 | ||
|
|
1172fa418e | ||
|
|
b368181ac9 | ||
|
|
7afa48b4ef | ||
|
|
45191ad144 | ||
|
|
2869922696 | ||
|
|
e48c1ccf07 | ||
|
|
5e5823ed85 | ||
|
|
de2bc25677 | ||
|
|
79b5ce58e9 | ||
|
|
088a81c116 |
8
.github/workflows/docs-locale-sync.yml
vendored
8
.github/workflows/docs-locale-sync.yml
vendored
@@ -65,9 +65,9 @@ jobs:
|
||||
"packages/web/src/content/docs/*/*.mdx": "allow",
|
||||
".opencode": "allow",
|
||||
".opencode/agent": "allow",
|
||||
".opencode/agent/glossary": "allow",
|
||||
".opencode/glossary": "allow",
|
||||
".opencode/agent/translator.md": "allow",
|
||||
".opencode/agent/glossary/*.md": "allow"
|
||||
".opencode/glossary/*.md": "allow"
|
||||
},
|
||||
"edit": {
|
||||
"*": "deny",
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
"glob": {
|
||||
"*": "deny",
|
||||
"packages/web/src/content/docs*": "allow",
|
||||
".opencode/agent/glossary*": "allow"
|
||||
".opencode/glossary*": "allow"
|
||||
},
|
||||
"task": {
|
||||
"*": "deny",
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
"read": {
|
||||
"*": "deny",
|
||||
".opencode/agent/translator.md": "allow",
|
||||
".opencode/agent/glossary/*.md": "allow"
|
||||
".opencode/glossary/*.md": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
.github/workflows/vouch-check-issue.yml
vendored
58
.github/workflows/vouch-check-issue.yml
vendored
@@ -42,15 +42,17 @@ jobs:
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for denounced users
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (!trimmed.startsWith('-')) continue;
|
||||
|
||||
const rest = trimmed.slice(1).trim();
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
@@ -65,32 +67,50 @@ jobs:
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason === undefined) {
|
||||
core.info(`User ${author} is not denounced. Allowing issue.`);
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the issue
|
||||
const body = 'This issue has been automatically closed.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is denounced — close the issue
|
||||
const body = 'This issue has been automatically closed.';
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
|
||||
|
||||
55
.github/workflows/vouch-check-pr.yml
vendored
55
.github/workflows/vouch-check-pr.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -42,15 +43,17 @@ jobs:
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for denounced users
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
if (!trimmed.startsWith('-')) continue;
|
||||
|
||||
const rest = trimmed.slice(1).trim();
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
@@ -65,29 +68,47 @@ jobs:
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason === undefined) {
|
||||
core.info(`User ${author} is not denounced. Allowing PR.`);
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the PR
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: 'This pull request has been automatically closed.',
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is denounced — close the PR
|
||||
await github.rest.issues.createComment({
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: 'This pull request has been automatically closed.',
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
|
||||
|
||||
1
.github/workflows/vouch-manage-by-issue.yml
vendored
1
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -33,5 +33,6 @@ jobs:
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
@@ -13,7 +13,7 @@ Requirements:
|
||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||
- Also apply locale-specific guidance from `.opencode/agent/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Do not modify fenced code blocks.
|
||||
- Output ONLY the translation (no commentary).
|
||||
|
||||
|
||||
30
bun.lock
30
bun.lock
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -262,7 +262,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -376,7 +376,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -396,7 +396,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -407,7 +407,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -420,7 +420,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -462,7 +462,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -473,7 +473,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -101,7 +101,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
})
|
||||
|
||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||
name: "OpenCode Lite",
|
||||
name: "OpenCode Go",
|
||||
})
|
||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||
product: zenLiteProduct.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
@@ -11,11 +11,23 @@ import {
|
||||
} from "../selectors"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
|
||||
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
async function withDockSession<T>(
|
||||
sdk: Sdk,
|
||||
title: string,
|
||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||
opts?: { permission?: PermissionRule[] },
|
||||
) {
|
||||
const session = await sdk.session
|
||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||
.then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
return fn(session)
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test.setTimeout(120_000)
|
||||
@@ -28,6 +40,85 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPermissionDock(page: any, label: RegExp) {
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const count = await dock.count()
|
||||
if (count === 0) return
|
||||
await dock.getByRole("button", { name: label }).click()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
}
|
||||
|
||||
async function withMockPermission<T>(
|
||||
page: any,
|
||||
request: {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
always?: string[]
|
||||
},
|
||||
opts: { child?: any } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) {
|
||||
let pending = [
|
||||
{
|
||||
...request,
|
||||
always: request.always ?? ["*"],
|
||||
metadata: request.metadata ?? {},
|
||||
},
|
||||
]
|
||||
|
||||
const list = async (route: any) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(pending),
|
||||
})
|
||||
}
|
||||
|
||||
const reply = async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const id = url.pathname.split("/").pop()
|
||||
pending = pending.filter((item) => item.id !== id)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(true),
|
||||
})
|
||||
}
|
||||
|
||||
await page.route("**/permission", list)
|
||||
await page.route("**/session/*/permissions/*", reply)
|
||||
|
||||
const sessionList = opts?.child
|
||||
? async (route: any) => {
|
||||
const res = await route.fetch()
|
||||
const json = await res.json()
|
||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||
await route.fulfill({
|
||||
status: res.status(),
|
||||
headers: res.headers(),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(json),
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (sessionList) await page.route("**/session?*", sessionList)
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
@@ -76,72 +167,175 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
})
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow once/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["REJECT.md"],
|
||||
})
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
try {
|
||||
await withDockSeed(sdk, child.id, async () => {
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow always/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await page.goto(page.url())
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Preporučeno",
|
||||
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
|
||||
"dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max",
|
||||
"dialog.provider.copilot.note": "Claude modeli za pomoć pri kodiranju",
|
||||
"dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke",
|
||||
"dialog.provider.google.note": "Gemini modeli za brze, strukturirane odgovore",
|
||||
"dialog.provider.openrouter.note": "Pristup svim podržanim modelima preko jednog provajdera",
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Anbefalet",
|
||||
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
|
||||
"dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max",
|
||||
"dialog.provider.copilot.note": "Claude-modeller til kodningsassistance",
|
||||
"dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver",
|
||||
"dialog.provider.google.note": "Gemini-modeller til hurtige, strukturerede svar",
|
||||
"dialog.provider.openrouter.note": "Få adgang til alle understøttede modeller fra én udbyder",
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Recommended",
|
||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||
"dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max",
|
||||
"dialog.provider.copilot.note": "Claude models for coding assistance",
|
||||
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
|
||||
"dialog.provider.google.note": "Gemini models for fast, structured responses",
|
||||
"dialog.provider.openrouter.note": "Access all supported models from one provider",
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Recomendado",
|
||||
"dialog.provider.opencode.note": "Modelos seleccionados incluyendo Claude, GPT, Gemini y más",
|
||||
"dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluyendo Pro y Max",
|
||||
"dialog.provider.copilot.note": "Modelos Claude para asistencia de codificación",
|
||||
"dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces",
|
||||
"dialog.provider.google.note": "Modelos Gemini para respuestas rápidas y estructuradas",
|
||||
"dialog.provider.openrouter.note": "Accede a todos los modelos soportados desde un solo proveedor",
|
||||
|
||||
@@ -103,7 +103,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Anbefalt",
|
||||
"dialog.provider.opencode.note": "Utvalgte modeller inkludert Claude, GPT, Gemini og mer",
|
||||
"dialog.provider.anthropic.note": "Direkte tilgang til Claude-modeller, inkludert Pro og Max",
|
||||
"dialog.provider.copilot.note": "Claude-modeller for kodeassistanse",
|
||||
"dialog.provider.copilot.note": "AI-modeller for kodeassistanse via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-modeller for raske, dyktige generelle AI-oppgaver",
|
||||
"dialog.provider.google.note": "Gemini-modeller for raske, strukturerte svar",
|
||||
"dialog.provider.openrouter.note": "Tilgang til alle støttede modeller fra én leverandør",
|
||||
|
||||
@@ -92,7 +92,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Zalecane",
|
||||
"dialog.provider.opencode.note": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne",
|
||||
"dialog.provider.anthropic.note": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max",
|
||||
"dialog.provider.copilot.note": "Modele Claude do pomocy w kodowaniu",
|
||||
"dialog.provider.copilot.note": "Modele AI do pomocy w kodowaniu przez GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modele GPT do szybkich i wszechstronnych zadań AI",
|
||||
"dialog.provider.google.note": "Modele Gemini do szybkich i ustrukturyzowanych odpowiedzi",
|
||||
"dialog.provider.openrouter.note": "Dostęp do wszystkich obsługiwanych modeli od jednego dostawcy",
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "Рекомендуемые",
|
||||
"dialog.provider.opencode.note": "Отобранные модели, включая Claude, GPT, Gemini и другие",
|
||||
"dialog.provider.anthropic.note": "Прямой доступ к моделям Claude, включая Pro и Max",
|
||||
"dialog.provider.copilot.note": "Модели Claude для помощи в кодировании",
|
||||
"dialog.provider.copilot.note": "ИИ-модели для помощи в кодировании через GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Модели GPT для быстрых и мощных задач общего ИИ",
|
||||
"dialog.provider.google.note": "Модели Gemini для быстрых и структурированных ответов",
|
||||
"dialog.provider.openrouter.note": "Доступ ко всем поддерживаемым моделям через одного провайдера",
|
||||
|
||||
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"dialog.provider.tag.recommended": "แนะนำ",
|
||||
"dialog.provider.opencode.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ",
|
||||
"dialog.provider.anthropic.note": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max",
|
||||
"dialog.provider.copilot.note": "โมเดล Claude สำหรับการช่วยเหลือในการเขียนโค้ด",
|
||||
"dialog.provider.copilot.note": "โมเดล AI สำหรับการช่วยเหลือในการเขียนโค้ดผ่าน GitHub Copilot",
|
||||
"dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ",
|
||||
"dialog.provider.google.note": "โมเดล Gemini สำหรับการตอบสนองที่รวดเร็วและมีโครงสร้าง",
|
||||
"dialog.provider.openrouter.note": "เข้าถึงโมเดลที่รองรับทั้งหมดจากผู้ให้บริการเดียว",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -15,19 +14,11 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
directory={props.directory}
|
||||
onPermissionRespond={(input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
}) => sdk.client.permission.respond(input)}
|
||||
onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
|
||||
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||
>
|
||||
|
||||
@@ -254,12 +254,13 @@ export default function Page() {
|
||||
const msgs = visibleUserMessages()
|
||||
if (msgs.length === 0) return
|
||||
|
||||
const current = activeMessage()
|
||||
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
|
||||
const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
|
||||
if (targetIndex < 0 || targetIndex >= msgs.length) return
|
||||
const current = store.messageId
|
||||
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
|
||||
const currentIndex = base === -1 ? msgs.length : base
|
||||
const targetIndex = currentIndex + offset
|
||||
if (targetIndex < 0 || targetIndex > msgs.length) return
|
||||
|
||||
if (targetIndex === msgs.length - 1) {
|
||||
if (targetIndex === msgs.length) {
|
||||
resumeScroll()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
}) as Session
|
||||
|
||||
const permission = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
}) as PermissionRequest
|
||||
|
||||
const question = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
questions: [],
|
||||
}) as QuestionRequest
|
||||
|
||||
describe("sessionPermissionRequest", () => {
|
||||
test("prefers the current session permission", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const permissions = {
|
||||
root: [permission("perm-root", "root")],
|
||||
child: [permission("perm-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root")
|
||||
})
|
||||
|
||||
test("returns a nested child permission", () => {
|
||||
const sessions = [
|
||||
session({ id: "root" }),
|
||||
session({ id: "child", parentID: "root" }),
|
||||
session({ id: "grand", parentID: "child" }),
|
||||
session({ id: "other" }),
|
||||
]
|
||||
const permissions = {
|
||||
grand: [permission("perm-grand", "grand")],
|
||||
other: [permission("perm-other", "other")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
|
||||
})
|
||||
|
||||
test("returns undefined without a matching tree permission", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const permissions = {
|
||||
other: [permission("perm-other", "other")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionQuestionRequest", () => {
|
||||
test("prefers the current session question", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const questions = {
|
||||
root: [question("q-root", "root")],
|
||||
child: [question("q-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
|
||||
})
|
||||
|
||||
test("returns a nested child question", () => {
|
||||
const sessions = [
|
||||
session({ id: "root" }),
|
||||
session({ id: "child", parentID: "root" }),
|
||||
session({ id: "grand", parentID: "child" }),
|
||||
]
|
||||
const questions = {
|
||||
grand: [question("q-grand", "grand")],
|
||||
}
|
||||
|
||||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
})
|
||||
@@ -7,14 +7,20 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
export function createSessionComposerBlocked() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const permissionRequest = createMemo(() =>
|
||||
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id),
|
||||
)
|
||||
const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
|
||||
|
||||
return createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,18 +32,18 @@ export function createSessionComposerState() {
|
||||
const language = useLanguage()
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.data.question[id]?.[0]
|
||||
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.data.permission[id]?.[0]
|
||||
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id)
|
||||
})
|
||||
|
||||
const blocked = createSessionComposerBlocked()
|
||||
const blocked = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] | undefined>, sessionID?: string) {
|
||||
if (!sessionID) return
|
||||
|
||||
const map = session.reduce((acc, item) => {
|
||||
if (!item.parentID) return acc
|
||||
const list = acc.get(item.parentID)
|
||||
if (list) list.push(item.id)
|
||||
if (!list) acc.set(item.parentID, [item.id])
|
||||
return acc
|
||||
}, new Map<string, string[]>())
|
||||
|
||||
const seen = new Set([sessionID])
|
||||
const ids = [sessionID]
|
||||
for (const id of ids) {
|
||||
const list = map.get(id)
|
||||
if (!list) continue
|
||||
for (const child of list) {
|
||||
if (seen.has(child)) continue
|
||||
seen.add(child)
|
||||
ids.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
const id = ids.find((id) => !!request[id]?.[0])
|
||||
if (!id) return
|
||||
return request[id]?.[0]
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, PermissionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID)
|
||||
}
|
||||
|
||||
export function sessionQuestionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, QuestionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID)
|
||||
}
|
||||
@@ -376,6 +376,7 @@ export function MessageTimeline(props: {
|
||||
>
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
|
||||
@@ -45,7 +45,9 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const top = a.top - b.top + root.scrollTop
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
|
||||
"start": "vite start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.13"
|
||||
version = "1.2.14"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.13/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.14/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,46 +2,62 @@
|
||||
|
||||
import { z } from "zod"
|
||||
import { Config } from "../src/config/config"
|
||||
import { TuiConfig } from "../src/config/tui"
|
||||
|
||||
const file = process.argv[2]
|
||||
console.log(file)
|
||||
function generate(schema: z.ZodType) {
|
||||
const result = z.toJSONSchema(schema, {
|
||||
io: "input", // Generate input shape (treats optional().default() as not required)
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
* This will ensure no docs are needed to be read, as the configuration is
|
||||
* self-documenting.
|
||||
*
|
||||
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
|
||||
*/
|
||||
override(ctx) {
|
||||
const schema = ctx.jsonSchema
|
||||
|
||||
const result = z.toJSONSchema(Config.Info, {
|
||||
io: "input", // Generate input shape (treats optional().default() as not required)
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
* This will ensure no docs are needed to be read, as the configuration is
|
||||
* self-documenting.
|
||||
*
|
||||
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
|
||||
*/
|
||||
override(ctx) {
|
||||
const schema = ctx.jsonSchema
|
||||
|
||||
// Preserve strictness: set additionalProperties: false for objects
|
||||
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
|
||||
schema.additionalProperties = false
|
||||
}
|
||||
|
||||
// Add examples and default descriptions for string fields with defaults
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
// Preserve strictness: set additionalProperties: false for objects
|
||||
if (
|
||||
schema &&
|
||||
typeof schema === "object" &&
|
||||
schema.type === "object" &&
|
||||
schema.additionalProperties === undefined
|
||||
) {
|
||||
schema.additionalProperties = false
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
}
|
||||
},
|
||||
}) as Record<string, unknown> & {
|
||||
allowComments?: boolean
|
||||
allowTrailingCommas?: boolean
|
||||
// Add examples and default descriptions for string fields with defaults
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
}
|
||||
},
|
||||
}) as Record<string, unknown> & {
|
||||
allowComments?: boolean
|
||||
allowTrailingCommas?: boolean
|
||||
}
|
||||
|
||||
// used for json lsps since config supports jsonc
|
||||
result.allowComments = true
|
||||
result.allowTrailingCommas = true
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// used for json lsps since config supports jsonc
|
||||
result.allowComments = true
|
||||
result.allowTrailingCommas = true
|
||||
const configFile = process.argv[2]
|
||||
const tuiFile = process.argv[3]
|
||||
|
||||
await Bun.write(file, JSON.stringify(result, null, 2))
|
||||
console.log(configFile)
|
||||
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
|
||||
|
||||
if (tuiFile) {
|
||||
console.log(tuiFile)
|
||||
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
|
||||
}
|
||||
|
||||
@@ -268,18 +268,17 @@ export const AuthLoginCommand = cmd({
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await text(proc.stdout)
|
||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
|
||||
@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Info
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
@@ -138,35 +141,37 @@ export function tui(input: {
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
|
||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const config = await Instance.provide({
|
||||
directory: directory && existsSync(directory) ? directory : process.cwd(),
|
||||
fn: () => TuiConfig.get(),
|
||||
})
|
||||
await tui({
|
||||
url: args.url,
|
||||
config,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
|
||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
@@ -22,7 +21,7 @@ export type Slash = {
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: keyof KeybindsConfig
|
||||
keybind?: KeybindKey
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
|
||||
@@ -80,11 +80,11 @@ const TIPS = [
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
|
||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
|
||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
|
||||
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
|
||||
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
|
||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
|
||||
"Configure {highlight}model{/highlight} in config to set your default model",
|
||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
|
||||
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
|
||||
"Set any keybind to {highlight}none{/highlight} to disable it completely",
|
||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
|
||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
|
||||
@@ -140,7 +140,7 @@ const TIPS = [
|
||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
|
||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
|
||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
|
||||
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const keybinds = createMemo(() => {
|
||||
const config = useTuiConfig()
|
||||
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
|
||||
return pipe(
|
||||
sync.data.config.keybinds ?? {},
|
||||
(config.keybinds ?? {}) as Record<string, string>,
|
||||
mapValues((value) => Keybind.parse(value)),
|
||||
)
|
||||
})
|
||||
@@ -78,7 +80,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: keyof KeybindsConfig, evt: ParsedKey) {
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
@@ -88,7 +90,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
}
|
||||
},
|
||||
print(key: keyof KeybindsConfig) {
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Glob } from "../../../../util/glob"
|
||||
import aura from "./theme/aura.json" with { type: "json" }
|
||||
@@ -42,6 +41,7 @@ import { useRenderer } from "@opentui/solid"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -280,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const sync = useSync()
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: kv.get("theme_mode", props.mode),
|
||||
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const theme = sync.data.config.theme
|
||||
const theme = config.theme
|
||||
if (theme) setStore("active", theme)
|
||||
})
|
||||
|
||||
|
||||
9
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
Normal file
9
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||
name: "TuiConfig",
|
||||
init: (props: { config: TuiConfig.Info }) => {
|
||||
return props.config
|
||||
},
|
||||
})
|
||||
@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -101,6 +102,7 @@ const context = createContext<{
|
||||
showGenericToolOutput: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
sync: ReturnType<typeof useSync>
|
||||
tui: ReturnType<typeof useTuiConfig>
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -113,6 +115,7 @@ export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
@@ -166,7 +169,7 @@ export function Session() {
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
|
||||
const scrollAcceleration = createMemo(() => {
|
||||
const tui = sync.data.config.tui
|
||||
const tui = tuiConfig
|
||||
if (tui?.scroll_acceleration?.enabled) {
|
||||
return new MacOSScrollAccel()
|
||||
}
|
||||
@@ -988,6 +991,7 @@ export function Session() {
|
||||
showGenericToolOutput,
|
||||
diffWrapMode,
|
||||
sync,
|
||||
tui: tuiConfig,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
@@ -1949,7 +1953,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
const diffStyle = ctx.tui.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
@@ -2003,7 +2007,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
const files = createMemo(() => props.metadata.files ?? [])
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
const diffStyle = ctx.tui.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@/global"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
const sync = useSync()
|
||||
const config = useTuiConfig()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
|
||||
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = sync.data.config.tui?.diff_style
|
||||
const diffStyle = config.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
return dimensions().width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -135,6 +137,10 @@ export const TuiThreadCommand = cmd({
|
||||
if (!args.prompt) return piped
|
||||
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||
})
|
||||
const config = await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: () => TuiConfig.get(),
|
||||
})
|
||||
|
||||
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
||||
const networkOpts = await resolveNetworkOptions(args)
|
||||
@@ -163,6 +169,8 @@ export const TuiThreadCommand = cmd({
|
||||
|
||||
const tuiPromise = tui({
|
||||
url,
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: customFetch,
|
||||
events,
|
||||
args: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe, unique } from "remeda"
|
||||
import { Global } from "../global"
|
||||
@@ -34,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Control } from "@/control"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -42,7 +43,7 @@ export namespace Config {
|
||||
|
||||
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
||||
// These settings override all user and project settings
|
||||
function getManagedConfigDir(): string {
|
||||
function systemManagedConfigDir(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/opencode"
|
||||
@@ -53,10 +54,14 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
|
||||
export function managedConfigDir() {
|
||||
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
|
||||
}
|
||||
|
||||
const managedDir = managedConfigDir()
|
||||
|
||||
// Custom merge function that concatenates array fields instead of replacing them
|
||||
function merge(target: Info, source: Info): Info {
|
||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
const merged = mergeDeep(target, source)
|
||||
if (target.plugin && source.plugin) {
|
||||
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
|
||||
@@ -91,7 +96,7 @@ export namespace Config {
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
// Add $schema to prevent load() from trying to write back to a non-existent file
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = merge(
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${key}/.well-known/opencode`),
|
||||
@@ -107,21 +112,18 @@ export namespace Config {
|
||||
}
|
||||
|
||||
// Global user config overrides remote config.
|
||||
result = merge(result, await global())
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
// Custom config path overrides global config.
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
|
||||
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
// Project config overrides global and remote config.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
||||
for (const resolved of found.toReversed()) {
|
||||
result = merge(result, await loadFile(resolved))
|
||||
}
|
||||
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
|
||||
result = mergeConfigConcatArrays(result, await loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,31 +131,10 @@ export namespace Config {
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = [
|
||||
Global.Path.config,
|
||||
// Only scan project .opencode/ directories when project discovery is enabled
|
||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: Instance.directory,
|
||||
stop: Instance.worktree,
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
// Always scan ~/.opencode/ (user home directory)
|
||||
...(await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: Global.Path.home,
|
||||
stop: Global.Path.home,
|
||||
}),
|
||||
)),
|
||||
]
|
||||
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||
|
||||
// .opencode directory config overrides (project and global) config sources.
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
directories.push(Flag.OPENCODE_CONFIG_DIR)
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
@@ -163,7 +144,7 @@ export namespace Config {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||
result = merge(result, await loadFile(path.join(dir, file)))
|
||||
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
|
||||
// to satisfy the type checker
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
@@ -186,7 +167,7 @@ export namespace Config {
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = merge(
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: Instance.directory,
|
||||
@@ -200,9 +181,9 @@ export namespace Config {
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
// This way it only loads config file and not skills/plugins/commands
|
||||
if (existsSync(managedConfigDir)) {
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
result = merge(result, await loadFile(path.join(managedConfigDir, file)))
|
||||
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,8 +222,6 @@ export namespace Config {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
|
||||
|
||||
// Apply flag overrides for compaction settings
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
@@ -306,7 +285,7 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
async function needsInstall(dir: string) {
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
@@ -930,20 +909,6 @@ export namespace Config {
|
||||
ref: "KeybindsConfig",
|
||||
})
|
||||
|
||||
export const TUI = z.object({
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Enable scroll acceleration"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Scroll acceleration settings"),
|
||||
diff_style: z
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
})
|
||||
|
||||
export const Server = z
|
||||
.object({
|
||||
port: z.number().int().positive().optional().describe("Port to listen on"),
|
||||
@@ -1018,10 +983,7 @@ export namespace Config {
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
||||
logLevel: Log.Level.optional().describe("Log level"),
|
||||
tui: TUI.optional().describe("TUI specific settings"),
|
||||
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
||||
command: z
|
||||
.record(z.string(), Command)
|
||||
@@ -1241,86 +1203,37 @@ export namespace Config {
|
||||
return result
|
||||
})
|
||||
|
||||
export const { readFile } = ConfigPaths
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
log.info("loading", { path: filepath })
|
||||
let text = await Filesystem.readText(filepath).catch((err: any) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
const text = await readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, { path: filepath })
|
||||
}
|
||||
|
||||
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
||||
const original = text
|
||||
const configDir = "path" in options ? path.dirname(options.path) : options.dir
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = await ConfigPaths.parseText(
|
||||
text,
|
||||
"path" in options ? options.path : { source: options.source, dir: options.dir },
|
||||
)
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
const normalized = (() => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
delete copy.keybinds
|
||||
delete copy.tui
|
||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||
return copy
|
||||
})()
|
||||
|
||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||
if (fileMatches) {
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const lineIndex = lines.findIndex((line) => line.includes(match))
|
||||
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
||||
continue
|
||||
}
|
||||
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Bun.file(resolvedPath)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: source,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: source,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
@@ -1353,13 +1266,7 @@ export namespace Config {
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
export const { JsonError, InvalidError } = ConfigPaths
|
||||
|
||||
export const ConfigDirectoryTypoError = NamedError.create(
|
||||
"ConfigDirectoryTypoError",
|
||||
@@ -1370,15 +1277,6 @@ export namespace Config {
|
||||
}),
|
||||
)
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"ConfigInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function get() {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
155
packages/opencode/src/config/migrate-tui-config.ts
Normal file
155
packages/opencode/src/config/migrate-tui-config.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import z from "zod"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@/global"
|
||||
|
||||
const log = Log.create({ service: "tui.migrate" })
|
||||
|
||||
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
|
||||
|
||||
const LegacyTheme = TuiInfo.shape.theme.optional()
|
||||
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
|
||||
|
||||
const TuiLegacy = z
|
||||
.object({
|
||||
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
|
||||
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
|
||||
diff_style: TuiOptions.shape.diff_style.catch(undefined),
|
||||
})
|
||||
.strip()
|
||||
|
||||
interface MigrateInput {
|
||||
directories: string[]
|
||||
custom?: string
|
||||
managed: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
|
||||
* into dedicated tui.json files. Migration is performed per-directory and
|
||||
* skips only locations where a tui.json already exists.
|
||||
*/
|
||||
export async function migrateTuiConfig(input: MigrateInput) {
|
||||
const opencode = await opencodeFiles(input)
|
||||
for (const file of opencode) {
|
||||
const source = await Filesystem.readText(file).catch((error) => {
|
||||
log.warn("failed to read config for tui migration", { path: file, error })
|
||||
return undefined
|
||||
})
|
||||
if (!source) continue
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
||||
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
||||
|
||||
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
|
||||
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
|
||||
const extracted = {
|
||||
theme: theme.success ? theme.data : undefined,
|
||||
keybinds: keybinds.success ? keybinds.data : undefined,
|
||||
tui: legacyTui.success ? legacyTui.data : undefined,
|
||||
}
|
||||
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
||||
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
|
||||
|
||||
const target = path.join(path.dirname(file), "tui.json")
|
||||
const targetExists = await Filesystem.exists(target)
|
||||
if (targetExists) continue
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
$schema: TUI_SCHEMA_URL,
|
||||
}
|
||||
if (extracted.theme !== undefined) payload.theme = extracted.theme
|
||||
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
|
||||
if (tui) Object.assign(payload, tui)
|
||||
|
||||
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to write tui migration target", { from: file, to: target, error })
|
||||
return false
|
||||
})
|
||||
if (!wrote) continue
|
||||
|
||||
const stripped = await backupAndStripLegacy(file, source)
|
||||
if (!stripped) {
|
||||
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
|
||||
continue
|
||||
}
|
||||
log.info("migrated tui config", { from: file, to: target })
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTui(data: Record<string, unknown>) {
|
||||
const parsed = TuiLegacy.parse(data)
|
||||
if (
|
||||
parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function backupAndStripLegacy(file: string, source: string) {
|
||||
const backup = file + ".tui-migration.bak"
|
||||
const hasBackup = await Filesystem.exists(backup)
|
||||
const backed = hasBackup
|
||||
? true
|
||||
: await Bun.write(backup, source)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
|
||||
return false
|
||||
})
|
||||
if (!backed) return false
|
||||
|
||||
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
|
||||
const edits = modify(acc, [key], undefined, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
},
|
||||
})
|
||||
if (!edits.length) return acc
|
||||
return applyEdits(acc, edits)
|
||||
}, source)
|
||||
|
||||
return Bun.write(file, text)
|
||||
.then(() => {
|
||||
log.info("stripped tui keys from server config", { path: file, backup })
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async function opencodeFiles(input: { directories: string[]; managed: string }) {
|
||||
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
|
||||
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
||||
for (const dir of unique(input.directories)) {
|
||||
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
||||
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
|
||||
|
||||
const existing = await Promise.all(
|
||||
unique(files).map(async (file) => {
|
||||
const ok = await Filesystem.exists(file)
|
||||
return ok ? file : undefined
|
||||
}),
|
||||
)
|
||||
return existing.filter((file): file is string => !!file)
|
||||
}
|
||||
174
packages/opencode/src/config/paths.ts
Normal file
174
packages/opencode/src/config/paths.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace ConfigPaths {
|
||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||
const files: string[] = []
|
||||
for (const file of [`${name}.jsonc`, `${name}.json`]) {
|
||||
const found = await Filesystem.findUp(file, directory, worktree)
|
||||
for (const resolved of found.toReversed()) {
|
||||
files.push(resolved)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export async function directories(directory: string, worktree: string) {
|
||||
return [
|
||||
Global.Path.config,
|
||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
...(await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".opencode"],
|
||||
start: Global.Path.home,
|
||||
stop: Global.Path.home,
|
||||
}),
|
||||
)),
|
||||
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
||||
]
|
||||
}
|
||||
|
||||
export function fileInDirectory(dir: string, name: string) {
|
||||
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"ConfigInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||
export async function readFile(filepath: string) {
|
||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
}
|
||||
|
||||
type ParseSource = string | { source: string; dir: string }
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return typeof input === "string" ? input : input.source
|
||||
}
|
||||
|
||||
function dir(input: ParseSource) {
|
||||
return typeof input === "string" ? path.dirname(input) : input.dir
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = dir(input)
|
||||
const configSource = source(input)
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (missing === "empty") return ""
|
||||
|
||||
const errMsg = `bad file reference: "${token}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
|
||||
out += JSON.stringify(fileContent).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
|
||||
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
const configSource = source(input)
|
||||
text = await substitute(text, input, missing)
|
||||
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: configSource,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
34
packages/opencode/src/config/tui-schema.ts
Normal file
34
packages/opencode/src/config/tui-schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import z from "zod"
|
||||
import { Config } from "./config"
|
||||
|
||||
const KeybindOverride = z
|
||||
.object(
|
||||
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
||||
string,
|
||||
z.ZodOptional<z.ZodString>
|
||||
>,
|
||||
)
|
||||
.strict()
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Enable scroll acceleration"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Scroll acceleration settings"),
|
||||
diff_style: z
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
118
packages/opencode/src/config/tui.ts
Normal file
118
packages/opencode/src/config/tui.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { existsSync } from "fs"
|
||||
import z from "zod"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Config } from "./config"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { migrateTuiConfig } from "./migrate-tui-config"
|
||||
import { TuiInfo } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
function mergeInfo(target: Info, source: Info): Info {
|
||||
return mergeDeep(target, source)
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||
const custom = customPath()
|
||||
const managed = Config.managedConfigDir()
|
||||
await migrateTuiConfig({ directories, custom, managed })
|
||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
let result: Info = {}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
result = mergeInfo(result, await loadFile(custom))
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
|
||||
return {
|
||||
config: result,
|
||||
}
|
||||
})
|
||||
|
||||
export async function get() {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = (() => {
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
if (!("tui" in copy)) return copy
|
||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
||||
delete copy.tui
|
||||
return copy
|
||||
}
|
||||
const tui = copy.tui as Record<string, unknown>
|
||||
delete copy.tui
|
||||
return {
|
||||
...tui,
|
||||
...copy,
|
||||
}
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (!parsed.success) {
|
||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
@@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_TUI_CONFIG
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because tests and external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
|
||||
get() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CONFIG_DIR
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because external tooling may set this env var at runtime
|
||||
|
||||
@@ -618,6 +618,42 @@ export const SessionRoutes = lazy(() =>
|
||||
return c.json(message)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/message/:messageID",
|
||||
describeRoute({
|
||||
summary: "Delete message",
|
||||
description:
|
||||
"Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.",
|
||||
operationId: "session.deleteMessage",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully deleted message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
messageID: z.string().meta({ description: "Message ID" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
SessionPrompt.assertNotBusy(params.sessionID)
|
||||
await Session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
describeRoute({
|
||||
|
||||
@@ -697,7 +697,9 @@ export namespace Session {
|
||||
async (input) => {
|
||||
// CASCADE delete handles parts automatically
|
||||
Database.use((db) => {
|
||||
db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run()
|
||||
db.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -717,7 +719,9 @@ export namespace Session {
|
||||
}),
|
||||
async (input) => {
|
||||
Database.use((db) => {
|
||||
db.delete(PartTable).where(eq(PartTable.id, input.partID)).run()
|
||||
db.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -56,6 +56,28 @@ test("loads JSON config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores legacy tui keys in opencode config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "test/model",
|
||||
theme: "legacy",
|
||||
tui: { scroll_speed: 4 },
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
||||
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -110,14 +132,14 @@ test("merges multiple config files with correct precedence", async () => {
|
||||
|
||||
test("handles environment variable substitution", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
process.env["TEST_VAR"] = "test_theme"
|
||||
process.env["TEST_VAR"] = "test-user"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{env:TEST_VAR}",
|
||||
username: "{env:TEST_VAR}",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -125,7 +147,7 @@ test("handles environment variable substitution", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_theme")
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -148,7 +170,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
theme: "{env:PRESERVE_VAR}",
|
||||
username: "{env:PRESERVE_VAR}",
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -157,7 +179,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("secret_value")
|
||||
expect(config.username).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
|
||||
@@ -178,10 +200,10 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
|
||||
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:included.txt}",
|
||||
username: "{file:included.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -189,7 +211,7 @@ test("handles file inclusion substitution", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_theme")
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -200,7 +222,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:included.md}",
|
||||
username: "{file:included.md}",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -208,7 +230,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1043,7 +1065,6 @@ test("managed settings override project settings", async () => {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
autoupdate: true,
|
||||
disabled_providers: [],
|
||||
theme: "dark",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1060,7 +1081,6 @@ test("managed settings override project settings", async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.autoupdate).toBe(false)
|
||||
expect(config.disabled_providers).toEqual(["openai"])
|
||||
expect(config.theme).toBe("dark")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1809,7 +1829,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{env:TEST_CONFIG_VAR}",
|
||||
username: "{env:TEST_CONFIG_VAR}",
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -1818,7 +1838,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_api_key_12345")
|
||||
expect(config.username).toBe("test_api_key_12345")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -1841,10 +1861,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
|
||||
await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:./api_key.txt}",
|
||||
username: "{file:./api_key.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1852,7 +1872,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("secret_key_from_file")
|
||||
expect(config.username).toBe("secret_key_from_file")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
510
packages/opencode/test/config/tui.test.ts
Normal file
510
packages/opencode/test/config/tui.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.OPENCODE_CONFIG
|
||||
delete process.env.OPENCODE_TUI_CONFIG
|
||||
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||
})
|
||||
|
||||
test("loads tui config with the same precedence order as server config paths", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, ".opencode", "tui.json"),
|
||||
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("local")
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
theme: "migrated-theme",
|
||||
tui: { scroll_speed: 5 },
|
||||
keybinds: { app_exit: "ctrl+q" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("migrated-theme")
|
||||
expect(config.scroll_speed).toBe(5)
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||
expect(JSON.parse(text)).toMatchObject({
|
||||
theme: "migrated-theme",
|
||||
scroll_speed: 5,
|
||||
})
|
||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||
expect(server.theme).toBeUndefined()
|
||||
expect(server.keybinds).toBeUndefined()
|
||||
expect(server.tui).toBeUndefined()
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
theme: "project-migrated",
|
||||
tui: { scroll_speed: 2 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("project-migrated")
|
||||
expect(config.scroll_speed).toBe(2)
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||
|
||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||
expect(server.theme).toBeUndefined()
|
||||
expect(server.tui).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drops unknown legacy tui keys during migration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
theme: "migrated-theme",
|
||||
tui: { scroll_speed: 2, foo: 1 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("migrated-theme")
|
||||
expect(config.scroll_speed).toBe(2)
|
||||
|
||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||
const migrated = JSON.parse(text)
|
||||
expect(migrated.scroll_speed).toBe(2)
|
||||
expect(migrated.foo).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
`{
|
||||
"theme": "broken-theme",
|
||||
"tui": { "scroll_speed": 2 }
|
||||
"username": "still-broken"
|
||||
}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBeUndefined()
|
||||
expect(config.scroll_speed).toBeUndefined()
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
|
||||
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
|
||||
expect(source).toContain('"theme": "broken-theme"')
|
||||
expect(source).toContain('"tui": { "scroll_speed": 2 }')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips migration when tui.json already exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
expect(config.theme).toBeUndefined()
|
||||
|
||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||
expect(server.theme).toBe("legacy")
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("continues loading tui config when legacy source cannot be stripped", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
const source = path.join(tmp.path, "opencode.json")
|
||||
await fs.chmod(source, 0o444)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("readonly-theme")
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||
|
||||
const server = JSON.parse(await Filesystem.readText(source))
|
||||
expect(server.theme).toBe("readonly-theme")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await fs.chmod(source, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
test("migration backup preserves JSONC comments", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
`{
|
||||
// top-level comment
|
||||
"theme": "jsonc-theme",
|
||||
"tui": {
|
||||
// nested comment
|
||||
"scroll_speed": 1.5
|
||||
}
|
||||
}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await TuiConfig.get()
|
||||
const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
|
||||
expect(backup).toContain("// top-level comment")
|
||||
expect(backup).toContain("// nested comment")
|
||||
expect(backup).toContain('"theme": "jsonc-theme"')
|
||||
expect(backup).toContain('"scroll_speed": 1.5')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const nested = path.join(dir, "apps", "client")
|
||||
await fs.mkdir(nested, { recursive: true })
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
|
||||
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "apps", "client"),
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("nested-theme")
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("flattens nested tui key inside tui.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "outer",
|
||||
tui: { scroll_speed: 3, diff_style: "stacked" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.scroll_speed).toBe(3)
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
// top-level keys take precedence over nested tui keys
|
||||
expect(config.theme).toBe("outer")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("top-level keys in tui.json take precedence over nested tui key", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
diff_style: "auto",
|
||||
tui: { diff_style: "stacked", scroll_speed: 2 },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.diff_style).toBe("auto")
|
||||
expect(config.scroll_speed).toBe(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
|
||||
const custom = path.join(dir, "custom-tui.json")
|
||||
await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
|
||||
process.env.OPENCODE_TUI_CONFIG = custom
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
// project tui.json overrides the custom path, same as server config precedence
|
||||
expect(config.theme).toBe("project")
|
||||
// project also set diff_style, so that wins
|
||||
expect(config.diff_style).toBe("auto")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("merges keybind overrides across precedence layers", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
expect(config.keybinds?.theme_list).toBe("ctrl+k")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const custom = path.join(dir, "custom-tui.json")
|
||||
await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
|
||||
process.env.OPENCODE_TUI_CONFIG = custom
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("from-env")
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not derive tui path from OPENCODE_CONFIG", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const customDir = path.join(dir, "custom")
|
||||
await fs.mkdir(customDir, { recursive: true })
|
||||
await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
|
||||
await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
|
||||
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies env and file substitutions in tui.json", async () => {
|
||||
const original = process.env.TUI_THEME_TEST
|
||||
process.env.TUI_THEME_TEST = "env-theme"
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "{env:TUI_THEME_TEST}",
|
||||
keybinds: { app_exit: "{file:keybind.txt}" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("env-theme")
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TUI_THEME_TEST
|
||||
else process.env.TUI_THEME_TEST = original
|
||||
}
|
||||
})
|
||||
|
||||
test("applies file substitutions when first identical token is in a commented line", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.jsonc"),
|
||||
`{
|
||||
// "theme": "{file:theme.txt}",
|
||||
"theme": "{file:theme.txt}"
|
||||
}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("resolved-theme")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads managed tui config and gives it highest precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
|
||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("managed-theme")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads .opencode/tui.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("gracefully falls back when tui.json has invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
|
||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("managed-fallback")
|
||||
expect(config.keybinds).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -107,6 +107,8 @@ import type {
|
||||
SessionCreateErrors,
|
||||
SessionCreateResponses,
|
||||
SessionDeleteErrors,
|
||||
SessionDeleteMessageErrors,
|
||||
SessionDeleteMessageResponses,
|
||||
SessionDeleteResponses,
|
||||
SessionDiffResponses,
|
||||
SessionForkResponses,
|
||||
@@ -1561,6 +1563,42 @@ export class Session2 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete message
|
||||
*
|
||||
* Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.
|
||||
*/
|
||||
public deleteMessage<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
directory?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "messageID" },
|
||||
{ in: "query", key: "directory" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).delete<
|
||||
SessionDeleteMessageResponses,
|
||||
SessionDeleteMessageErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/session/{sessionID}/message/{messageID}",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message
|
||||
*
|
||||
|
||||
@@ -991,388 +991,6 @@ export type GlobalEvent = {
|
||||
payload: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom keybind configurations
|
||||
*/
|
||||
export type KeybindsConfig = {
|
||||
/**
|
||||
* Leader key for keybind combinations
|
||||
*/
|
||||
leader?: string
|
||||
/**
|
||||
* Exit the application
|
||||
*/
|
||||
app_exit?: string
|
||||
/**
|
||||
* Open external editor
|
||||
*/
|
||||
editor_open?: string
|
||||
/**
|
||||
* List available themes
|
||||
*/
|
||||
theme_list?: string
|
||||
/**
|
||||
* Toggle sidebar
|
||||
*/
|
||||
sidebar_toggle?: string
|
||||
/**
|
||||
* Toggle session scrollbar
|
||||
*/
|
||||
scrollbar_toggle?: string
|
||||
/**
|
||||
* Toggle username visibility
|
||||
*/
|
||||
username_toggle?: string
|
||||
/**
|
||||
* View status
|
||||
*/
|
||||
status_view?: string
|
||||
/**
|
||||
* Export session to editor
|
||||
*/
|
||||
session_export?: string
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
session_new?: string
|
||||
/**
|
||||
* List all sessions
|
||||
*/
|
||||
session_list?: string
|
||||
/**
|
||||
* Show session timeline
|
||||
*/
|
||||
session_timeline?: string
|
||||
/**
|
||||
* Fork session from message
|
||||
*/
|
||||
session_fork?: string
|
||||
/**
|
||||
* Rename session
|
||||
*/
|
||||
session_rename?: string
|
||||
/**
|
||||
* Delete session
|
||||
*/
|
||||
session_delete?: string
|
||||
/**
|
||||
* Delete stash entry
|
||||
*/
|
||||
stash_delete?: string
|
||||
/**
|
||||
* Open provider list from model dialog
|
||||
*/
|
||||
model_provider_list?: string
|
||||
/**
|
||||
* Toggle model favorite status
|
||||
*/
|
||||
model_favorite_toggle?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
session_share?: string
|
||||
/**
|
||||
* Unshare current session
|
||||
*/
|
||||
session_unshare?: string
|
||||
/**
|
||||
* Interrupt current session
|
||||
*/
|
||||
session_interrupt?: string
|
||||
/**
|
||||
* Compact the session
|
||||
*/
|
||||
session_compact?: string
|
||||
/**
|
||||
* Scroll messages up by one page
|
||||
*/
|
||||
messages_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by one page
|
||||
*/
|
||||
messages_page_down?: string
|
||||
/**
|
||||
* Scroll messages up by one line
|
||||
*/
|
||||
messages_line_up?: string
|
||||
/**
|
||||
* Scroll messages down by one line
|
||||
*/
|
||||
messages_line_down?: string
|
||||
/**
|
||||
* Scroll messages up by half page
|
||||
*/
|
||||
messages_half_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by half page
|
||||
*/
|
||||
messages_half_page_down?: string
|
||||
/**
|
||||
* Navigate to first message
|
||||
*/
|
||||
messages_first?: string
|
||||
/**
|
||||
* Navigate to last message
|
||||
*/
|
||||
messages_last?: string
|
||||
/**
|
||||
* Navigate to next message
|
||||
*/
|
||||
messages_next?: string
|
||||
/**
|
||||
* Navigate to previous message
|
||||
*/
|
||||
messages_previous?: string
|
||||
/**
|
||||
* Navigate to last user message
|
||||
*/
|
||||
messages_last_user?: string
|
||||
/**
|
||||
* Copy message
|
||||
*/
|
||||
messages_copy?: string
|
||||
/**
|
||||
* Undo message
|
||||
*/
|
||||
messages_undo?: string
|
||||
/**
|
||||
* Redo message
|
||||
*/
|
||||
messages_redo?: string
|
||||
/**
|
||||
* Toggle code block concealment in messages
|
||||
*/
|
||||
messages_toggle_conceal?: string
|
||||
/**
|
||||
* Toggle tool details visibility
|
||||
*/
|
||||
tool_details?: string
|
||||
/**
|
||||
* List available models
|
||||
*/
|
||||
model_list?: string
|
||||
/**
|
||||
* Next recently used model
|
||||
*/
|
||||
model_cycle_recent?: string
|
||||
/**
|
||||
* Previous recently used model
|
||||
*/
|
||||
model_cycle_recent_reverse?: string
|
||||
/**
|
||||
* Next favorite model
|
||||
*/
|
||||
model_cycle_favorite?: string
|
||||
/**
|
||||
* Previous favorite model
|
||||
*/
|
||||
model_cycle_favorite_reverse?: string
|
||||
/**
|
||||
* List available commands
|
||||
*/
|
||||
command_list?: string
|
||||
/**
|
||||
* List agents
|
||||
*/
|
||||
agent_list?: string
|
||||
/**
|
||||
* Next agent
|
||||
*/
|
||||
agent_cycle?: string
|
||||
/**
|
||||
* Previous agent
|
||||
*/
|
||||
agent_cycle_reverse?: string
|
||||
/**
|
||||
* Cycle model variants
|
||||
*/
|
||||
variant_cycle?: string
|
||||
/**
|
||||
* Clear input field
|
||||
*/
|
||||
input_clear?: string
|
||||
/**
|
||||
* Paste from clipboard
|
||||
*/
|
||||
input_paste?: string
|
||||
/**
|
||||
* Submit input
|
||||
*/
|
||||
input_submit?: string
|
||||
/**
|
||||
* Insert newline in input
|
||||
*/
|
||||
input_newline?: string
|
||||
/**
|
||||
* Move cursor left in input
|
||||
*/
|
||||
input_move_left?: string
|
||||
/**
|
||||
* Move cursor right in input
|
||||
*/
|
||||
input_move_right?: string
|
||||
/**
|
||||
* Move cursor up in input
|
||||
*/
|
||||
input_move_up?: string
|
||||
/**
|
||||
* Move cursor down in input
|
||||
*/
|
||||
input_move_down?: string
|
||||
/**
|
||||
* Select left in input
|
||||
*/
|
||||
input_select_left?: string
|
||||
/**
|
||||
* Select right in input
|
||||
*/
|
||||
input_select_right?: string
|
||||
/**
|
||||
* Select up in input
|
||||
*/
|
||||
input_select_up?: string
|
||||
/**
|
||||
* Select down in input
|
||||
*/
|
||||
input_select_down?: string
|
||||
/**
|
||||
* Move to start of line in input
|
||||
*/
|
||||
input_line_home?: string
|
||||
/**
|
||||
* Move to end of line in input
|
||||
*/
|
||||
input_line_end?: string
|
||||
/**
|
||||
* Select to start of line in input
|
||||
*/
|
||||
input_select_line_home?: string
|
||||
/**
|
||||
* Select to end of line in input
|
||||
*/
|
||||
input_select_line_end?: string
|
||||
/**
|
||||
* Move to start of visual line in input
|
||||
*/
|
||||
input_visual_line_home?: string
|
||||
/**
|
||||
* Move to end of visual line in input
|
||||
*/
|
||||
input_visual_line_end?: string
|
||||
/**
|
||||
* Select to start of visual line in input
|
||||
*/
|
||||
input_select_visual_line_home?: string
|
||||
/**
|
||||
* Select to end of visual line in input
|
||||
*/
|
||||
input_select_visual_line_end?: string
|
||||
/**
|
||||
* Move to start of buffer in input
|
||||
*/
|
||||
input_buffer_home?: string
|
||||
/**
|
||||
* Move to end of buffer in input
|
||||
*/
|
||||
input_buffer_end?: string
|
||||
/**
|
||||
* Select to start of buffer in input
|
||||
*/
|
||||
input_select_buffer_home?: string
|
||||
/**
|
||||
* Select to end of buffer in input
|
||||
*/
|
||||
input_select_buffer_end?: string
|
||||
/**
|
||||
* Delete line in input
|
||||
*/
|
||||
input_delete_line?: string
|
||||
/**
|
||||
* Delete to end of line in input
|
||||
*/
|
||||
input_delete_to_line_end?: string
|
||||
/**
|
||||
* Delete to start of line in input
|
||||
*/
|
||||
input_delete_to_line_start?: string
|
||||
/**
|
||||
* Backspace in input
|
||||
*/
|
||||
input_backspace?: string
|
||||
/**
|
||||
* Delete character in input
|
||||
*/
|
||||
input_delete?: string
|
||||
/**
|
||||
* Undo in input
|
||||
*/
|
||||
input_undo?: string
|
||||
/**
|
||||
* Redo in input
|
||||
*/
|
||||
input_redo?: string
|
||||
/**
|
||||
* Move word forward in input
|
||||
*/
|
||||
input_word_forward?: string
|
||||
/**
|
||||
* Move word backward in input
|
||||
*/
|
||||
input_word_backward?: string
|
||||
/**
|
||||
* Select word forward in input
|
||||
*/
|
||||
input_select_word_forward?: string
|
||||
/**
|
||||
* Select word backward in input
|
||||
*/
|
||||
input_select_word_backward?: string
|
||||
/**
|
||||
* Delete word forward in input
|
||||
*/
|
||||
input_delete_word_forward?: string
|
||||
/**
|
||||
* Delete word backward in input
|
||||
*/
|
||||
input_delete_word_backward?: string
|
||||
/**
|
||||
* Previous history item
|
||||
*/
|
||||
history_previous?: string
|
||||
/**
|
||||
* Next history item
|
||||
*/
|
||||
history_next?: string
|
||||
/**
|
||||
* Next child session
|
||||
*/
|
||||
session_child_cycle?: string
|
||||
/**
|
||||
* Previous child session
|
||||
*/
|
||||
session_child_cycle_reverse?: string
|
||||
/**
|
||||
* Go to parent session
|
||||
*/
|
||||
session_parent?: string
|
||||
/**
|
||||
* Suspend terminal
|
||||
*/
|
||||
terminal_suspend?: string
|
||||
/**
|
||||
* Toggle terminal title
|
||||
*/
|
||||
terminal_title_toggle?: string
|
||||
/**
|
||||
* Toggle tips on home screen
|
||||
*/
|
||||
tips_toggle?: string
|
||||
/**
|
||||
* Toggle thinking blocks visibility
|
||||
*/
|
||||
display_thinking?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level
|
||||
*/
|
||||
@@ -1672,34 +1290,7 @@ export type Config = {
|
||||
* JSON schema reference for configuration validation
|
||||
*/
|
||||
$schema?: string
|
||||
/**
|
||||
* Theme name to use for the interface
|
||||
*/
|
||||
theme?: string
|
||||
keybinds?: KeybindsConfig
|
||||
logLevel?: LogLevel
|
||||
/**
|
||||
* TUI specific settings
|
||||
*/
|
||||
tui?: {
|
||||
/**
|
||||
* TUI scroll speed
|
||||
*/
|
||||
scroll_speed?: number
|
||||
/**
|
||||
* Scroll acceleration settings
|
||||
*/
|
||||
scroll_acceleration?: {
|
||||
/**
|
||||
* Enable scroll acceleration
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
/**
|
||||
* Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
|
||||
*/
|
||||
diff_style?: "auto" | "stacked"
|
||||
}
|
||||
server?: ServerConfig
|
||||
/**
|
||||
* Command configuration, see https://opencode.ai/docs/commands
|
||||
@@ -3564,6 +3155,46 @@ export type SessionPromptResponses = {
|
||||
|
||||
export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses]
|
||||
|
||||
export type SessionDeleteMessageData = {
|
||||
body?: never
|
||||
path: {
|
||||
/**
|
||||
* Session ID
|
||||
*/
|
||||
sessionID: string
|
||||
/**
|
||||
* Message ID
|
||||
*/
|
||||
messageID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/session/{sessionID}/message/{messageID}"
|
||||
}
|
||||
|
||||
export type SessionDeleteMessageErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors]
|
||||
|
||||
export type SessionDeleteMessageResponses = {
|
||||
/**
|
||||
* Successfully deleted message
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses]
|
||||
|
||||
export type SessionMessageData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -2630,6 +2630,76 @@
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})"
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "session.deleteMessage",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "sessionID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"description": "Session ID"
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "messageID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"description": "Message ID"
|
||||
}
|
||||
],
|
||||
"summary": "Delete message",
|
||||
"description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully deleted message",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session/{sessionID}/message/{messageID}/part/{partID}": {
|
||||
@@ -8651,483 +8721,6 @@
|
||||
},
|
||||
"required": ["directory", "payload"]
|
||||
},
|
||||
"KeybindsConfig": {
|
||||
"description": "Custom keybind configurations",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"description": "Leader key for keybind combinations",
|
||||
"default": "ctrl+x",
|
||||
"type": "string"
|
||||
},
|
||||
"app_exit": {
|
||||
"description": "Exit the application",
|
||||
"default": "ctrl+c,ctrl+d,<leader>q",
|
||||
"type": "string"
|
||||
},
|
||||
"editor_open": {
|
||||
"description": "Open external editor",
|
||||
"default": "<leader>e",
|
||||
"type": "string"
|
||||
},
|
||||
"theme_list": {
|
||||
"description": "List available themes",
|
||||
"default": "<leader>t",
|
||||
"type": "string"
|
||||
},
|
||||
"sidebar_toggle": {
|
||||
"description": "Toggle sidebar",
|
||||
"default": "<leader>b",
|
||||
"type": "string"
|
||||
},
|
||||
"scrollbar_toggle": {
|
||||
"description": "Toggle session scrollbar",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"username_toggle": {
|
||||
"description": "Toggle username visibility",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"status_view": {
|
||||
"description": "View status",
|
||||
"default": "<leader>s",
|
||||
"type": "string"
|
||||
},
|
||||
"session_export": {
|
||||
"description": "Export session to editor",
|
||||
"default": "<leader>x",
|
||||
"type": "string"
|
||||
},
|
||||
"session_new": {
|
||||
"description": "Create a new session",
|
||||
"default": "<leader>n",
|
||||
"type": "string"
|
||||
},
|
||||
"session_list": {
|
||||
"description": "List all sessions",
|
||||
"default": "<leader>l",
|
||||
"type": "string"
|
||||
},
|
||||
"session_timeline": {
|
||||
"description": "Show session timeline",
|
||||
"default": "<leader>g",
|
||||
"type": "string"
|
||||
},
|
||||
"session_fork": {
|
||||
"description": "Fork session from message",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"session_rename": {
|
||||
"description": "Rename session",
|
||||
"default": "ctrl+r",
|
||||
"type": "string"
|
||||
},
|
||||
"session_delete": {
|
||||
"description": "Delete session",
|
||||
"default": "ctrl+d",
|
||||
"type": "string"
|
||||
},
|
||||
"stash_delete": {
|
||||
"description": "Delete stash entry",
|
||||
"default": "ctrl+d",
|
||||
"type": "string"
|
||||
},
|
||||
"model_provider_list": {
|
||||
"description": "Open provider list from model dialog",
|
||||
"default": "ctrl+a",
|
||||
"type": "string"
|
||||
},
|
||||
"model_favorite_toggle": {
|
||||
"description": "Toggle model favorite status",
|
||||
"default": "ctrl+f",
|
||||
"type": "string"
|
||||
},
|
||||
"session_share": {
|
||||
"description": "Share current session",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"session_unshare": {
|
||||
"description": "Unshare current session",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"session_interrupt": {
|
||||
"description": "Interrupt current session",
|
||||
"default": "escape",
|
||||
"type": "string"
|
||||
},
|
||||
"session_compact": {
|
||||
"description": "Compact the session",
|
||||
"default": "<leader>c",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_page_up": {
|
||||
"description": "Scroll messages up by one page",
|
||||
"default": "pageup,ctrl+alt+b",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_page_down": {
|
||||
"description": "Scroll messages down by one page",
|
||||
"default": "pagedown,ctrl+alt+f",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_line_up": {
|
||||
"description": "Scroll messages up by one line",
|
||||
"default": "ctrl+alt+y",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_line_down": {
|
||||
"description": "Scroll messages down by one line",
|
||||
"default": "ctrl+alt+e",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"description": "Scroll messages up by half page",
|
||||
"default": "ctrl+alt+u",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"description": "Scroll messages down by half page",
|
||||
"default": "ctrl+alt+d",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_first": {
|
||||
"description": "Navigate to first message",
|
||||
"default": "ctrl+g,home",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_last": {
|
||||
"description": "Navigate to last message",
|
||||
"default": "ctrl+alt+g,end",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_next": {
|
||||
"description": "Navigate to next message",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_previous": {
|
||||
"description": "Navigate to previous message",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_last_user": {
|
||||
"description": "Navigate to last user message",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_copy": {
|
||||
"description": "Copy message",
|
||||
"default": "<leader>y",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_undo": {
|
||||
"description": "Undo message",
|
||||
"default": "<leader>u",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_redo": {
|
||||
"description": "Redo message",
|
||||
"default": "<leader>r",
|
||||
"type": "string"
|
||||
},
|
||||
"messages_toggle_conceal": {
|
||||
"description": "Toggle code block concealment in messages",
|
||||
"default": "<leader>h",
|
||||
"type": "string"
|
||||
},
|
||||
"tool_details": {
|
||||
"description": "Toggle tool details visibility",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"model_list": {
|
||||
"description": "List available models",
|
||||
"default": "<leader>m",
|
||||
"type": "string"
|
||||
},
|
||||
"model_cycle_recent": {
|
||||
"description": "Next recently used model",
|
||||
"default": "f2",
|
||||
"type": "string"
|
||||
},
|
||||
"model_cycle_recent_reverse": {
|
||||
"description": "Previous recently used model",
|
||||
"default": "shift+f2",
|
||||
"type": "string"
|
||||
},
|
||||
"model_cycle_favorite": {
|
||||
"description": "Next favorite model",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"model_cycle_favorite_reverse": {
|
||||
"description": "Previous favorite model",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"command_list": {
|
||||
"description": "List available commands",
|
||||
"default": "ctrl+p",
|
||||
"type": "string"
|
||||
},
|
||||
"agent_list": {
|
||||
"description": "List agents",
|
||||
"default": "<leader>a",
|
||||
"type": "string"
|
||||
},
|
||||
"agent_cycle": {
|
||||
"description": "Next agent",
|
||||
"default": "tab",
|
||||
"type": "string"
|
||||
},
|
||||
"agent_cycle_reverse": {
|
||||
"description": "Previous agent",
|
||||
"default": "shift+tab",
|
||||
"type": "string"
|
||||
},
|
||||
"variant_cycle": {
|
||||
"description": "Cycle model variants",
|
||||
"default": "ctrl+t",
|
||||
"type": "string"
|
||||
},
|
||||
"input_clear": {
|
||||
"description": "Clear input field",
|
||||
"default": "ctrl+c",
|
||||
"type": "string"
|
||||
},
|
||||
"input_paste": {
|
||||
"description": "Paste from clipboard",
|
||||
"default": "ctrl+v",
|
||||
"type": "string"
|
||||
},
|
||||
"input_submit": {
|
||||
"description": "Submit input",
|
||||
"default": "return",
|
||||
"type": "string"
|
||||
},
|
||||
"input_newline": {
|
||||
"description": "Insert newline in input",
|
||||
"default": "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
"type": "string"
|
||||
},
|
||||
"input_move_left": {
|
||||
"description": "Move cursor left in input",
|
||||
"default": "left,ctrl+b",
|
||||
"type": "string"
|
||||
},
|
||||
"input_move_right": {
|
||||
"description": "Move cursor right in input",
|
||||
"default": "right,ctrl+f",
|
||||
"type": "string"
|
||||
},
|
||||
"input_move_up": {
|
||||
"description": "Move cursor up in input",
|
||||
"default": "up",
|
||||
"type": "string"
|
||||
},
|
||||
"input_move_down": {
|
||||
"description": "Move cursor down in input",
|
||||
"default": "down",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_left": {
|
||||
"description": "Select left in input",
|
||||
"default": "shift+left",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_right": {
|
||||
"description": "Select right in input",
|
||||
"default": "shift+right",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_up": {
|
||||
"description": "Select up in input",
|
||||
"default": "shift+up",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_down": {
|
||||
"description": "Select down in input",
|
||||
"default": "shift+down",
|
||||
"type": "string"
|
||||
},
|
||||
"input_line_home": {
|
||||
"description": "Move to start of line in input",
|
||||
"default": "ctrl+a",
|
||||
"type": "string"
|
||||
},
|
||||
"input_line_end": {
|
||||
"description": "Move to end of line in input",
|
||||
"default": "ctrl+e",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_line_home": {
|
||||
"description": "Select to start of line in input",
|
||||
"default": "ctrl+shift+a",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_line_end": {
|
||||
"description": "Select to end of line in input",
|
||||
"default": "ctrl+shift+e",
|
||||
"type": "string"
|
||||
},
|
||||
"input_visual_line_home": {
|
||||
"description": "Move to start of visual line in input",
|
||||
"default": "alt+a",
|
||||
"type": "string"
|
||||
},
|
||||
"input_visual_line_end": {
|
||||
"description": "Move to end of visual line in input",
|
||||
"default": "alt+e",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_visual_line_home": {
|
||||
"description": "Select to start of visual line in input",
|
||||
"default": "alt+shift+a",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_visual_line_end": {
|
||||
"description": "Select to end of visual line in input",
|
||||
"default": "alt+shift+e",
|
||||
"type": "string"
|
||||
},
|
||||
"input_buffer_home": {
|
||||
"description": "Move to start of buffer in input",
|
||||
"default": "home",
|
||||
"type": "string"
|
||||
},
|
||||
"input_buffer_end": {
|
||||
"description": "Move to end of buffer in input",
|
||||
"default": "end",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_buffer_home": {
|
||||
"description": "Select to start of buffer in input",
|
||||
"default": "shift+home",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_buffer_end": {
|
||||
"description": "Select to end of buffer in input",
|
||||
"default": "shift+end",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete_line": {
|
||||
"description": "Delete line in input",
|
||||
"default": "ctrl+shift+d",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete_to_line_end": {
|
||||
"description": "Delete to end of line in input",
|
||||
"default": "ctrl+k",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete_to_line_start": {
|
||||
"description": "Delete to start of line in input",
|
||||
"default": "ctrl+u",
|
||||
"type": "string"
|
||||
},
|
||||
"input_backspace": {
|
||||
"description": "Backspace in input",
|
||||
"default": "backspace,shift+backspace",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete": {
|
||||
"description": "Delete character in input",
|
||||
"default": "ctrl+d,delete,shift+delete",
|
||||
"type": "string"
|
||||
},
|
||||
"input_undo": {
|
||||
"description": "Undo in input",
|
||||
"default": "ctrl+-,super+z",
|
||||
"type": "string"
|
||||
},
|
||||
"input_redo": {
|
||||
"description": "Redo in input",
|
||||
"default": "ctrl+.,super+shift+z",
|
||||
"type": "string"
|
||||
},
|
||||
"input_word_forward": {
|
||||
"description": "Move word forward in input",
|
||||
"default": "alt+f,alt+right,ctrl+right",
|
||||
"type": "string"
|
||||
},
|
||||
"input_word_backward": {
|
||||
"description": "Move word backward in input",
|
||||
"default": "alt+b,alt+left,ctrl+left",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_word_forward": {
|
||||
"description": "Select word forward in input",
|
||||
"default": "alt+shift+f,alt+shift+right",
|
||||
"type": "string"
|
||||
},
|
||||
"input_select_word_backward": {
|
||||
"description": "Select word backward in input",
|
||||
"default": "alt+shift+b,alt+shift+left",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete_word_forward": {
|
||||
"description": "Delete word forward in input",
|
||||
"default": "alt+d,alt+delete,ctrl+delete",
|
||||
"type": "string"
|
||||
},
|
||||
"input_delete_word_backward": {
|
||||
"description": "Delete word backward in input",
|
||||
"default": "ctrl+w,ctrl+backspace,alt+backspace",
|
||||
"type": "string"
|
||||
},
|
||||
"history_previous": {
|
||||
"description": "Previous history item",
|
||||
"default": "up",
|
||||
"type": "string"
|
||||
},
|
||||
"history_next": {
|
||||
"description": "Next history item",
|
||||
"default": "down",
|
||||
"type": "string"
|
||||
},
|
||||
"session_child_cycle": {
|
||||
"description": "Next child session",
|
||||
"default": "<leader>right",
|
||||
"type": "string"
|
||||
},
|
||||
"session_child_cycle_reverse": {
|
||||
"description": "Previous child session",
|
||||
"default": "<leader>left",
|
||||
"type": "string"
|
||||
},
|
||||
"session_parent": {
|
||||
"description": "Go to parent session",
|
||||
"default": "<leader>up",
|
||||
"type": "string"
|
||||
},
|
||||
"terminal_suspend": {
|
||||
"description": "Suspend terminal",
|
||||
"default": "ctrl+z",
|
||||
"type": "string"
|
||||
},
|
||||
"terminal_title_toggle": {
|
||||
"description": "Toggle terminal title",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"tips_toggle": {
|
||||
"description": "Toggle tips on home screen",
|
||||
"default": "<leader>h",
|
||||
"type": "string"
|
||||
},
|
||||
"display_thinking": {
|
||||
"description": "Toggle thinking blocks visibility",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LogLevel": {
|
||||
"description": "Log level",
|
||||
"type": "string",
|
||||
@@ -9707,43 +9300,9 @@
|
||||
"description": "JSON schema reference for configuration validation",
|
||||
"type": "string"
|
||||
},
|
||||
"theme": {
|
||||
"description": "Theme name to use for the interface",
|
||||
"type": "string"
|
||||
},
|
||||
"keybinds": {
|
||||
"$ref": "#/components/schemas/KeybindsConfig"
|
||||
},
|
||||
"logLevel": {
|
||||
"$ref": "#/components/schemas/LogLevel"
|
||||
},
|
||||
"tui": {
|
||||
"description": "TUI specific settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scroll_speed": {
|
||||
"description": "TUI scroll speed",
|
||||
"type": "number",
|
||||
"minimum": 0.001
|
||||
},
|
||||
"scroll_acceleration": {
|
||||
"description": "Scroll acceleration settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enable scroll acceleration",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"]
|
||||
},
|
||||
"diff_style": {
|
||||
"description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
"type": "string",
|
||||
"enum": ["auto", "stacked"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/components/schemas/ServerConfig"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -252,6 +252,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-component="reasoning-part"] [data-component="markdown"] :not(pre) > code {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-error"] {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
@@ -654,105 +660,6 @@
|
||||
|
||||
[data-component="tool-part-wrapper"] {
|
||||
width: 100%;
|
||||
|
||||
&[data-permission="true"],
|
||||
&[data-question="true"] {
|
||||
position: sticky;
|
||||
top: calc(2px + var(--sticky-header-height, 40px));
|
||||
bottom: 0px;
|
||||
z-index: 20;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
background-color: var(--surface-raised-base);
|
||||
overflow: visible;
|
||||
overflow-anchor: none;
|
||||
|
||||
& > *:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="collapsible"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="card"] {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-permission="true"] {
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1.5px;
|
||||
top: -5px;
|
||||
border-radius: 7.5px;
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--background-base) 0 0) padding-box,
|
||||
conic-gradient(
|
||||
from var(--border-angle),
|
||||
transparent 0deg,
|
||||
transparent 0deg,
|
||||
var(--border-warning-strong, var(--border-warning-selected)) 300deg,
|
||||
var(--border-warning-base) 360deg
|
||||
)
|
||||
border-box;
|
||||
animation: chase-border 2.5s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-question="true"] {
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes chase-border {
|
||||
from {
|
||||
--border-angle: 0deg;
|
||||
}
|
||||
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="permission-prompt"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--surface-raised-strong);
|
||||
border-radius: 0 0 6px 6px;
|
||||
|
||||
[data-slot="permission-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-component="button"] {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dock-prompt"][data-kind="permission"] {
|
||||
@@ -867,7 +774,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) {
|
||||
[data-component="dock-prompt"][data-kind="question"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -23,11 +23,9 @@ import {
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
Todo,
|
||||
QuestionRequest,
|
||||
QuestionAnswer,
|
||||
QuestionInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useCodeComponent } from "../context/code"
|
||||
@@ -37,7 +35,6 @@ import { BasicTool } from "./basic-tool"
|
||||
import { GenericTool } from "./basic-tool"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Button } from "./button"
|
||||
import { Card } from "./card"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { FileIcon } from "./file-icon"
|
||||
@@ -950,7 +947,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
|
||||
}
|
||||
|
||||
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const part = props.part as ToolPart
|
||||
if (part.tool === "todowrite" || part.tool === "todoread") return null
|
||||
@@ -959,75 +955,18 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
|
||||
)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const next = data.store.permission?.[props.message.sessionID]?.[0]
|
||||
if (!next || !next.tool) return undefined
|
||||
if (next.tool!.callID !== part.callID) return undefined
|
||||
return next
|
||||
})
|
||||
|
||||
const questionRequest = createMemo(() => {
|
||||
const next = data.store.question?.[props.message.sessionID]?.[0]
|
||||
if (!next || !next.tool) return undefined
|
||||
if (next.tool!.callID !== part.callID) return undefined
|
||||
return next
|
||||
})
|
||||
|
||||
const [showPermission, setShowPermission] = createSignal(false)
|
||||
const [showQuestion, setShowQuestion] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const perm = permission()
|
||||
if (perm) {
|
||||
const timeout = setTimeout(() => setShowPermission(true), 50)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
} else {
|
||||
setShowPermission(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const question = questionRequest()
|
||||
if (question) {
|
||||
const timeout = setTimeout(() => setShowQuestion(true), 50)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
} else {
|
||||
setShowQuestion(false)
|
||||
}
|
||||
})
|
||||
|
||||
const [forceOpen, setForceOpen] = createSignal(false)
|
||||
createEffect(() => {
|
||||
if (permission() || questionRequest()) setForceOpen(true)
|
||||
})
|
||||
|
||||
const respond = (response: "once" | "always" | "reject") => {
|
||||
const perm = permission()
|
||||
if (!perm || !data.respondToPermission) return
|
||||
data.respondToPermission({
|
||||
sessionID: perm.sessionID,
|
||||
permissionID: perm.id,
|
||||
response,
|
||||
})
|
||||
}
|
||||
|
||||
const emptyInput: Record<string, any> = {}
|
||||
const emptyMetadata: Record<string, any> = {}
|
||||
|
||||
const input = () => part.state?.input ?? emptyInput
|
||||
// @ts-expect-error
|
||||
const partMetadata = () => part.state?.metadata ?? emptyMetadata
|
||||
const metadata = () => {
|
||||
const perm = permission()
|
||||
if (perm?.metadata) return { ...perm.metadata, ...partMetadata() }
|
||||
return partMetadata()
|
||||
}
|
||||
|
||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
||||
|
||||
return (
|
||||
<Show when={!hideQuestion()}>
|
||||
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
|
||||
<div data-component="tool-part-wrapper">
|
||||
<Switch>
|
||||
<Match when={part.state.status === "error" && part.state.error}>
|
||||
{(error) => {
|
||||
@@ -1067,33 +1006,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
component={render}
|
||||
input={input()}
|
||||
tool={part.tool}
|
||||
metadata={metadata()}
|
||||
metadata={partMetadata()}
|
||||
// @ts-expect-error
|
||||
output={part.state.output}
|
||||
status={part.state.status}
|
||||
hideDetails={props.hideDetails}
|
||||
forceOpen={forceOpen()}
|
||||
locked={showPermission() || showQuestion()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={showPermission() && permission()}>
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-actions">
|
||||
<Button variant="ghost" size="normal" onClick={() => respond("reject")}>
|
||||
{i18n.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="normal" onClick={() => respond("always")}>
|
||||
{i18n.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="primary" size="normal" onClick={() => respond("once")}>
|
||||
{i18n.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
@@ -1963,245 +1884,3 @@ ToolRegistry.register({
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
editing: false,
|
||||
})
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
data.replyToQuestion?.({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
data.rejectQuestion?.({
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
if (single()) {
|
||||
data.replyToQuestion?.({
|
||||
requestID: props.request.id,
|
||||
answers: [[answer]],
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
}
|
||||
|
||||
function toggle(answer: string) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
}
|
||||
|
||||
function selectTab(index: number) {
|
||||
setStore("tab", index)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
function selectOption(optIndex: number) {
|
||||
if (optIndex === options().length) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
function handleCustomSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const value = input().trim()
|
||||
if (!value) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (multi()) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (!next.includes(value)) next.push(value)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
pick(value, true)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="question-prompt">
|
||||
<Show when={!single()}>
|
||||
<div data-slot="question-tabs">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const active = () => index() === store.tab
|
||||
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<button
|
||||
data-slot="question-tab"
|
||||
data-active={active()}
|
||||
data-answered={answered()}
|
||||
onClick={() => selectTab(index())}
|
||||
>
|
||||
{q.header}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
|
||||
{i18n.t("ui.common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">
|
||||
{question()?.question}
|
||||
{multi() ? " " + i18n.t("ui.question.multiHint") : ""}
|
||||
</div>
|
||||
<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()} onClick={() => selectOption(i())}>
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<Show when={picked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={customPicked()}
|
||||
onClick={() => selectOption(options().length)}
|
||||
>
|
||||
<span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<Show when={!store.editing && input()}>
|
||||
<span data-slot="option-description">{input()}</span>
|
||||
</Show>
|
||||
<Show when={customPicked()}>
|
||||
<Icon name="check-small" size="normal" />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={store.editing}>
|
||||
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
||||
<input
|
||||
ref={(el) => setTimeout(() => el.focus(), 0)}
|
||||
type="text"
|
||||
data-slot="custom-input"
|
||||
placeholder={i18n.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
onInput={(e) => {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = e.currentTarget.value
|
||||
setStore("custom", inputs)
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="small">
|
||||
{multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
|
||||
{i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={confirm()}>
|
||||
<div data-slot="question-review">
|
||||
<div data-slot="review-title">{i18n.t("ui.messagePart.review.title")}</div>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<div data-slot="review-item">
|
||||
<span data-slot="review-label">{q.question}</span>
|
||||
<span data-slot="review-value" data-answered={answered()}>
|
||||
{answered() ? value() : i18n.t("ui.question.review.notAnswered")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div data-slot="question-actions">
|
||||
<Button variant="ghost" size="small" onClick={reject}>
|
||||
{i18n.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<Show when={!single()}>
|
||||
<Show when={confirm()}>
|
||||
<Button variant="primary" size="small" onClick={submit}>
|
||||
{i18n.t("ui.common.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!confirm() && multi()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => selectTab(store.tab + 1)}
|
||||
disabled={(store.answers[store.tab]?.length ?? 0) === 0}
|
||||
>
|
||||
{i18n.t("ui.common.next")}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import type {
|
||||
Message,
|
||||
Session,
|
||||
Part,
|
||||
FileDiff,
|
||||
SessionStatus,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
QuestionAnswer,
|
||||
ProviderListResponse,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
@@ -24,12 +14,6 @@ type Data = {
|
||||
session_diff_preload?: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
permission?: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question?: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
@@ -38,16 +22,6 @@ type Data = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
}) => void
|
||||
|
||||
export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void
|
||||
|
||||
export type QuestionRejectFn = (input: { requestID: string }) => void
|
||||
|
||||
export type NavigateToSessionFn = (sessionID: string) => void
|
||||
|
||||
export type SessionHrefFn = (sessionID: string) => string
|
||||
@@ -57,9 +31,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
init: (props: {
|
||||
data: Data
|
||||
directory: string
|
||||
onPermissionRespond?: PermissionRespondFn
|
||||
onQuestionReply?: QuestionReplyFn
|
||||
onQuestionReject?: QuestionRejectFn
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
}) => {
|
||||
@@ -70,9 +41,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
get directory() {
|
||||
return props.directory
|
||||
},
|
||||
respondToPermission: props.onPermissionRespond,
|
||||
replyToQuestion: props.onQuestionReply,
|
||||
rejectQuestion: props.onQuestionReject,
|
||||
navigateToSession: props.onNavigateToSession,
|
||||
sessionHref: props.onSessionHref,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -314,7 +314,7 @@ function configSchema() {
|
||||
hooks: {
|
||||
"astro:build:done": async () => {
|
||||
console.log("generating config schema")
|
||||
spawnSync("../opencode/script/schema.ts", ["./dist/config.json"])
|
||||
spawnSync("../opencode/script/schema.ts", ["./dist/config.json", "./dist/tui.json"])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -41,7 +41,7 @@ description: شارك محادثات OpenCode الخاصة بك.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ description: شارك محادثات OpenCode الخاصة بك.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ description: شارك محادثات OpenCode الخاصة بك.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Da eksplicitno postavite rucni nacin u [config datoteci](/docs/config):
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Mozete ukljuciti automatsko dijeljenje za sve nove razgovore tako sto `share` po
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Dijeljenje mozete potpuno iskljuciti tako sto `share` postavite na `"disabled"`
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -558,6 +558,7 @@ OpenCode can be configured using environment variables.
|
||||
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
|
||||
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
|
||||
| `OPENCODE_CONFIG` | string | Path to config file |
|
||||
| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file |
|
||||
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
|
||||
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
|
||||
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
|
||||
|
||||
@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// Theme configuration
|
||||
"theme": "opencode",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"autoupdate": true,
|
||||
"server": {
|
||||
"port": 4096,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced.
|
||||
|
||||
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
|
||||
|
||||
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
|
||||
For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
|
||||
|
||||
---
|
||||
|
||||
@@ -95,7 +96,9 @@ You can enable specific servers in your local config:
|
||||
|
||||
### Global
|
||||
|
||||
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
|
||||
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
|
||||
|
||||
For TUI-specific settings, use `~/.config/opencode/tui.json`.
|
||||
|
||||
Global config overrides remote organizational defaults.
|
||||
|
||||
@@ -105,6 +108,8 @@ Global config overrides remote organizational defaults.
|
||||
|
||||
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
|
||||
|
||||
For project-specific TUI settings, add `tui.json` alongside it.
|
||||
|
||||
:::tip
|
||||
Place project specific config in the root of your project.
|
||||
:::
|
||||
@@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori
|
||||
|
||||
## Schema
|
||||
|
||||
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
||||
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
||||
|
||||
TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
|
||||
|
||||
Your editor should be able to validate and autocomplete based on the schema.
|
||||
|
||||
@@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema.
|
||||
|
||||
### TUI
|
||||
|
||||
You can configure TUI-specific settings through the `tui` option.
|
||||
Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tui": {
|
||||
"scroll_speed": 3,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto"
|
||||
}
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"scroll_speed": 3,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
Available options:
|
||||
Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
|
||||
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
|
||||
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
|
||||
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
|
||||
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
|
||||
|
||||
[Learn more about using the TUI here](/docs/tui).
|
||||
[Learn more about TUI configuration here](/docs/tui#configure).
|
||||
|
||||
---
|
||||
|
||||
@@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
|
||||
|
||||
### Themes
|
||||
|
||||
You can configure the theme you want to use in your OpenCode config through the `theme` option.
|
||||
Set your UI theme in `tui.json`.
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"theme": ""
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "tokyonight"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
|
||||
|
||||
### Keybinds
|
||||
|
||||
You can customize your keybinds through the `keybinds` option.
|
||||
Customize keybinds in `tui.json`.
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ For at eksplisitt angi manuell modus i [konfigurasjonsfilen](/docs/config):
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Du kan aktivere automatisk deling for alle nye samtaler ved at sette alternative
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Du kan deaktivere deling helt ved at sette alternativet `share` til `"disabled"`
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ Um den manuellen Modus explizit in der [Konfiguration](/docs/config) zu setzen:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -56,7 +56,7 @@ Du kannst automatisches Teilen fuer neue Unterhaltungen aktivieren, indem du in
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -71,7 +71,7 @@ Du kannst Teilen komplett deaktivieren, indem du in der [Konfiguration](/docs/co
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Para configurar explícitamente el modo manual en su [archivo de configuración]
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Puede habilitar el uso compartido automático para todas las conversaciones nuev
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Puede desactivar el uso compartido por completo configurando la opción `share`
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Pour définir explicitement le mode manuel dans votre [fichier de configuration]
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Vous pouvez activer le partage automatique pour toutes les nouvelles conversatio
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Vous pouvez désactiver entièrement le partage en définissant l'option `share`
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Per impostare esplicitamente la modalita manuale nel tuo [file di config](/docs/
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Puoi abilitare la condivisione automatica per tutte le nuove conversazioni impos
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Puoi disabilitare completamente la condivisione impostando l'opzione `share` su
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -3,11 +3,11 @@ title: Keybinds
|
||||
description: Customize your keybinds.
|
||||
---
|
||||
|
||||
OpenCode has a list of keybinds that you can customize through the OpenCode config.
|
||||
OpenCode has a list of keybinds that you can customize through `tui.json`.
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x",
|
||||
"app_exit": "ctrl+c,ctrl+d,<leader>q",
|
||||
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
|
||||
|
||||
## Disable keybind
|
||||
|
||||
You can disable a keybind by adding the key to your config with a value of "none".
|
||||
You can disable a keybind by adding the key to `tui.json` with a value of "none".
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"session_compact": "none"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ For å eksplisitt angi manuell modus i [konfigurasjonsfilen](/docs/config):
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Du kan aktivere automatisk deling for alle nye samtaler ved å sette alternative
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Du kan deaktivere deling helt ved å sette alternativet `share` til `"disabled"`
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Aby jawnie ustawić tryb ręczny w [pliku konfiguracyjnym] (./config):
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Możesz włączyć automatyczne udostępnianie dla wszystkich nowych rozmów, us
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Możesz całkowicie wyłączyć udostępnianie, ustawiając opcję `share` na `"
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ Para definir explicitamente o modo manual em seu [arquivo de configuração](/do
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ Você pode habilitar o compartilhamento automático para todas as novas conversa
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ Você pode desativar o compartilhamento completamente definindo a opção `share
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ opencode поддерживает три режима общего доступ
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ opencode поддерживает три режима общего доступ
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "auto"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ opencode поддерживает три режима общего доступ
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opncd.ai/config.json",
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user