mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-19 07:14:20 +00:00
Compare commits
38 Commits
temp
...
chore/dupl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838523317f | ||
|
|
9ce7afdb5d | ||
|
|
84643f4192 | ||
|
|
34e69735cb | ||
|
|
b9ec4e6b97 | ||
|
|
a344a766fd | ||
|
|
bca793d064 | ||
|
|
007e633465 | ||
|
|
c0e0156680 | ||
|
|
ad3c192837 | ||
|
|
5512231ca8 | ||
|
|
bad394cd49 | ||
|
|
3b97580621 | ||
|
|
cb88fe26aa | ||
|
|
e345b89ce5 | ||
|
|
26c7b240ba | ||
|
|
d327a2b1cf | ||
|
|
c1b03b728a | ||
|
|
2a2437bf22 | ||
|
|
4ccb82e81a | ||
|
|
92912219df | ||
|
|
bab3124e8b | ||
|
|
7a66ec6bc9 | ||
|
|
3a505b2691 | ||
|
|
20f43372f6 | ||
|
|
fb79dd7bf8 | ||
|
|
4025b655a4 | ||
|
|
7379903568 | ||
|
|
a685e7a805 | ||
|
|
ce7484b4f5 | ||
|
|
0bc1dcbe1b | ||
|
|
a69b339baf | ||
|
|
26f835cdd2 | ||
|
|
bd3d1413fd | ||
|
|
2c17a980ff | ||
|
|
b784c923a8 | ||
|
|
ea96f898c0 | ||
|
|
47435f6e17 |
133
.github/workflows/duplicate-issues.yml
vendored
133
.github/workflows/duplicate-issues.yml
vendored
@@ -18,100 +18,65 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Build prompt
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const issue = context.payload.issue
|
||||
const body = issue.body ?? ""
|
||||
const text = [
|
||||
"Check this new issue for compliance and duplicates:",
|
||||
"",
|
||||
`CURRENT_ISSUE_NUMBER: ${issue.number}`,
|
||||
"",
|
||||
`Title: ${issue.title}`,
|
||||
"",
|
||||
"Description:",
|
||||
body,
|
||||
].join("\n")
|
||||
|
||||
fs.writeFileSync("issue_info.txt", text)
|
||||
|
||||
- name: Check duplicates and compliance
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh issue*": "allow"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
|
||||
bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details and return either a comment body or No action required" > issue_comment.txt
|
||||
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
- name: Post comment and label issue
|
||||
env:
|
||||
COMMENT: ${{ github.workspace }}/issue_comment.txt
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const comment = fs.readFileSync(process.env.COMMENT, "utf8").trim()
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
if (comment === "No action required") {
|
||||
core.info("No comment needed")
|
||||
return
|
||||
}
|
||||
|
||||
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: `_The following comment was made by an LLM, it may be inaccurate:_\n\n${comment}`,
|
||||
})
|
||||
|
||||
---
|
||||
if (!comment.includes("<!-- issue-compliance -->")) return
|
||||
|
||||
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
|
||||
|
||||
Check whether the issue follows our contributing guidelines and issue templates.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
1. Bug Report - requires a Description field with real content
|
||||
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||
3. Question - requires the Question field with real content
|
||||
|
||||
Additionally check:
|
||||
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
|
||||
- The issue has real content, not just template placeholder text left unchanged
|
||||
- Bug reports should include some context about how to reproduce
|
||||
- Feature requests should explain the problem or need
|
||||
- We want to push for having the user provide system description & information
|
||||
|
||||
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||
|
||||
---
|
||||
|
||||
TASK 2: DUPLICATE CHECK
|
||||
|
||||
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
|
||||
|
||||
---
|
||||
|
||||
POSTING YOUR COMMENT:
|
||||
|
||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||
|
||||
If the issue is NOT compliant, start the comment with:
|
||||
<!-- issue-compliance -->
|
||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
|
||||
If duplicates were found, include a section about potential duplicates with links.
|
||||
|
||||
If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
|
||||
|
||||
If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
|
||||
|
||||
Use this format for the comment:
|
||||
|
||||
[If not compliant:]
|
||||
<!-- issue-compliance -->
|
||||
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
|
||||
|
||||
**What needs to be fixed:**
|
||||
- [specific reasons]
|
||||
|
||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||
|
||||
[If duplicates found, add:]
|
||||
---
|
||||
This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
[If keybind-related, add:]
|
||||
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
||||
|
||||
[End with if not compliant:]
|
||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||
|
||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ["needs:compliance"],
|
||||
})
|
||||
|
||||
1
.github/workflows/pr-standards.yml
vendored
1
.github/workflows/pr-standards.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
||||
github.event.pull_request.user.login != 'fwang' &&
|
||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
||||
github.event.pull_request.user.login != 'R44VC0RP' &&
|
||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
86
.opencode/agent/duplicate-issue.md
Normal file
86
.opencode/agent/duplicate-issue.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/claude-haiku-4-5
|
||||
color: "#E67E22"
|
||||
tools:
|
||||
"*": false
|
||||
"github-issue-search": true
|
||||
---
|
||||
|
||||
You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues.
|
||||
|
||||
You have two jobs:
|
||||
|
||||
1. Check if the issue follows our issue templates/contributing requirements.
|
||||
2. Check for potential duplicate issues.
|
||||
|
||||
Use the github-issue-search tool to find potentially related issues.
|
||||
|
||||
IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself.
|
||||
|
||||
## Compliance checks
|
||||
|
||||
This project has three issue templates:
|
||||
|
||||
1. Bug Report - needs a Description field with real content.
|
||||
2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description.
|
||||
3. Question - needs a Question field with real content.
|
||||
|
||||
Also check:
|
||||
|
||||
- no AI-generated walls of text
|
||||
- required sections are not placeholder-only / unchanged template text
|
||||
- bug reports include some repro context
|
||||
- feature requests explain the problem/need
|
||||
- encourage system information where relevant
|
||||
|
||||
Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue).
|
||||
|
||||
## Duplicate checks
|
||||
|
||||
Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize:
|
||||
|
||||
- similar title/description
|
||||
- same error/symptoms
|
||||
- same component/feature area
|
||||
|
||||
If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997.
|
||||
|
||||
## Output rules
|
||||
|
||||
If the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly:
|
||||
|
||||
No action required
|
||||
|
||||
Otherwise output exactly one markdown comment body with this structure:
|
||||
|
||||
- If non-compliant, start with:
|
||||
|
||||
<!-- issue-compliance -->
|
||||
|
||||
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
|
||||
|
||||
**What needs to be fixed:**
|
||||
|
||||
- [specific reason]
|
||||
|
||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||
|
||||
- If duplicates were found, add:
|
||||
|
||||
---
|
||||
|
||||
This issue might be a duplicate of existing issues. Please check:
|
||||
|
||||
- #1234: [brief reason]
|
||||
|
||||
- If keybind-related, add:
|
||||
|
||||
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
||||
|
||||
- If non-compliant, end with:
|
||||
|
||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||
|
||||
Keep output concise. Do not wrap output in code fences.
|
||||
@@ -69,6 +69,10 @@ Examples:
|
||||
- Provider integration issues
|
||||
- New, broken, or poor-quality models
|
||||
|
||||
#### acp
|
||||
|
||||
If the issue mentions acp support, assign acp label.
|
||||
|
||||
#### docs
|
||||
|
||||
Add if the issue requests better documentation or docs updates.
|
||||
@@ -130,3 +134,7 @@ Determinism rules:
|
||||
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||
|
||||
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
ACP:
|
||||
|
||||
- rekram1-node (assign any acp issues to rekram1-node)
|
||||
|
||||
57
.opencode/tool/github-issue-search.ts
Normal file
57
.opencode/tool/github-issue-search.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-issue-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
interface Issue {
|
||||
title: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query for issue titles and descriptions"),
|
||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
|
||||
},
|
||||
async execute(args) {
|
||||
const owner = "anomalyco"
|
||||
const repo = "opencode"
|
||||
|
||||
const page = Math.floor(args.offset / args.limit) + 1
|
||||
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:issue state:open`)
|
||||
const result = await githubFetch(
|
||||
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
|
||||
)
|
||||
|
||||
if (result.total_count === 0) {
|
||||
return `No issues found matching "${args.query}"`
|
||||
}
|
||||
|
||||
const issues = result.items as Issue[]
|
||||
|
||||
if (issues.length === 0) {
|
||||
return `No other issues found matching "${args.query}"`
|
||||
}
|
||||
|
||||
const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n")
|
||||
|
||||
return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}`
|
||||
},
|
||||
})
|
||||
10
.opencode/tool/github-issue-search.txt
Normal file
10
.opencode/tool/github-issue-search.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Use this tool to search GitHub issues by title and description.
|
||||
|
||||
This tool searches issues in the sst/opencode repository and returns LLM-friendly results including:
|
||||
- Issue number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in issue titles or descriptions.
|
||||
@@ -68,17 +68,7 @@ export default tool({
|
||||
results.push("Dropped label: nix (issue does not mention nix)")
|
||||
}
|
||||
|
||||
const assignee = nix
|
||||
? "rekram1-node"
|
||||
: web
|
||||
? pick(TEAM.desktop)
|
||||
: args.assignee === "jlongster"
|
||||
? "thdxr"
|
||||
: args.assignee
|
||||
|
||||
if (args.assignee === "jlongster" && assignee === "thdxr") {
|
||||
results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)")
|
||||
}
|
||||
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||
|
||||
if (labels.includes("zen") && !zen) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
|
||||
@@ -71,13 +71,13 @@ const kindLabel = (kind: Kind) => {
|
||||
const kindTextColor = (kind: Kind) => {
|
||||
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||
return "color: var(--icon-warning-active)"
|
||||
return "color: var(--icon-diff-modified-base)"
|
||||
}
|
||||
|
||||
const kindDotColor = (kind: Kind) => {
|
||||
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||
return "background-color: var(--icon-warning-active)"
|
||||
return "background-color: var(--icon-diff-modified-base)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
@@ -447,12 +447,13 @@ export default function FileTree(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const kind = () => visibleKind(node, kinds(), marks())
|
||||
const active = () => !!kind() && !node.ignored
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -530,7 +531,30 @@ export default function FileTree(props: {
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||
<Switch>
|
||||
<Match when={node.ignored}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style="color: var(--icon-weak-base)"
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active()}>
|
||||
<FileIcon
|
||||
node={node}
|
||||
class="size-4 filetree-icon filetree-icon--mono"
|
||||
style={kindTextColor(kind()!)}
|
||||
mono
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</Match>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -26,6 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
@@ -108,6 +109,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 44
|
||||
|
||||
const scrollCursorIntoView = () => {
|
||||
const container = scrollRef
|
||||
@@ -117,7 +119,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!editorRef.contains(range.startContainer)) return
|
||||
|
||||
const rect = range.getBoundingClientRect()
|
||||
const cursor = getCursorPosition(editorRef)
|
||||
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
|
||||
if (cursor >= length) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
return
|
||||
}
|
||||
|
||||
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
|
||||
if (!rect.height) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
@@ -130,8 +139,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (bottom > container.scrollTop + container.clientHeight - padding) {
|
||||
container.scrollTop = bottom - container.clientHeight + padding
|
||||
if (bottom > container.scrollTop + container.clientHeight - inset) {
|
||||
container.scrollTop = bottom - container.clientHeight + inset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +250,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return messages.some((m) => m.role === "user")
|
||||
})
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
const [history, setHistory] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<{
|
||||
@@ -311,6 +319,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
requestAnimationFrame(() => editorRef?.focus())
|
||||
}
|
||||
|
||||
const shellModeKey = "mod+shift+x"
|
||||
const normalModeKey = "mod+shift+e"
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
@@ -320,6 +331,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
disabled: store.mode !== "normal",
|
||||
onSelect: pick,
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.shell",
|
||||
title: language.t("command.prompt.mode.shell"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: shellModeKey,
|
||||
disabled: store.mode === "shell",
|
||||
onSelect: () => setMode("shell"),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.normal",
|
||||
title: language.t("command.prompt.mode.normal"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: normalModeKey,
|
||||
disabled: store.mode === "normal",
|
||||
onSelect: () => setMode("normal"),
|
||||
},
|
||||
])
|
||||
|
||||
const closePopover = () => setStore("popover", null)
|
||||
@@ -1059,8 +1086,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
class="relative"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
@@ -1074,40 +1100,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</Show>
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
@@ -1330,59 +1358,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
data-component="prompt-mode-toggle"
|
||||
class="relative h-7 w-[68px] rounded-[4px] bg-surface-inset-base border border-[0.5px] border-border-weak-base p-0 flex items-center gap-1 overflow-visible"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 w-[calc((100%-4px)/2)] rounded-[4px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-xs-border)] transition-transform duration-200 ease-out will-change-transform"
|
||||
style={{
|
||||
transform: store.mode === "shell" ? "translateX(0px)" : "translateX(calc(100% + 4px))",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex-1 h-full p-0.5 flex items-center justify-center"
|
||||
aria-pressed={store.mode === "shell"}
|
||||
onClick={() => setMode("shell")}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center rounded-[2px] transition-colors hover:bg-surface-inset-base"
|
||||
classList={{ "hover:bg-transparent": store.mode === "shell" }}
|
||||
<div class="shrink-0" data-component="prompt-mode-toggle">
|
||||
<RadioGroup
|
||||
options={["shell", "normal"] as const}
|
||||
current={store.mode}
|
||||
value={(mode) => mode}
|
||||
label={(mode) => (
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t(mode === "shell" ? "command.prompt.mode.shell" : "command.prompt.mode.normal")}
|
||||
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||
class="size-full flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
name="console"
|
||||
name={mode === "shell" ? "console" : "prompt"}
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-strong-base": store.mode === "shell",
|
||||
"text-icon-weak": store.mode !== "shell",
|
||||
"text-icon-strong-base": mode === "shell" && store.mode === "shell",
|
||||
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
|
||||
"text-icon-weak": store.mode !== mode,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex-1 h-full p-0.5 flex items-center justify-center"
|
||||
aria-pressed={store.mode === "normal"}
|
||||
onClick={() => setMode("normal")}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center rounded-[2px] transition-colors hover:bg-surface-inset-base"
|
||||
classList={{ "hover:bg-transparent": store.mode === "normal" }}
|
||||
>
|
||||
<Icon
|
||||
name="prompt"
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-interactive-base": store.mode === "normal",
|
||||
"text-icon-weak": store.mode !== "normal",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</TooltipKeybind>
|
||||
)}
|
||||
onSelect={(mode) => mode && setMode(mode)}
|
||||
fill
|
||||
pad="none"
|
||||
class="w-[68px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
@@ -232,9 +233,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="question-prompt" ref={(el) => (root = el)}>
|
||||
<div data-slot="question-body">
|
||||
<div data-slot="question-header">
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
@@ -254,172 +257,169 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">
|
||||
{input() || language.t("ui.question.custom.placeholder")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<div data-slot="question-footer">
|
||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ export function SessionHeader() {
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-2 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
@@ -468,7 +468,7 @@ export function SessionHeader() {
|
||||
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
|
||||
@@ -196,7 +196,7 @@ export function StatusPopover() {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] pr-2 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
|
||||
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
|
||||
@@ -346,7 +346,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data) => t.write(data))
|
||||
output = terminalWriter((data, done) => t.write(data, done))
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
@@ -520,9 +520,19 @@ export const Terminal = (props: TerminalProps) => {
|
||||
disposed = true
|
||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
output?.flush()
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
cleanup()
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
|
||||
|
||||
const finalize = () => {
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
cleanup()
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
|
||||
output.flush(finalize)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,6 +63,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
||||
"command.model.variant.cycle": "تغيير جهد التفكير",
|
||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||
"command.prompt.mode.shell": "التبديل إلى وضع Shell",
|
||||
"command.prompt.mode.normal": "التبديل إلى وضع Prompt",
|
||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||
@@ -210,6 +212,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
||||
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc للخروج",
|
||||
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
||||
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
||||
|
||||
@@ -63,6 +63,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
|
||||
"command.model.variant.cycle": "Alternar nível de raciocínio",
|
||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||
"command.prompt.mode.shell": "Alternar para o modo Shell",
|
||||
"command.prompt.mode.normal": "Alternar para o modo Prompt",
|
||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||
@@ -210,6 +212,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
||||
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc para sair",
|
||||
"prompt.example.1": "Corrigir um TODO no código",
|
||||
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
|
||||
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
|
||||
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
||||
"command.prompt.mode.shell": "Prebaci na Shell način",
|
||||
"command.prompt.mode.normal": "Prebaci na Prompt način",
|
||||
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
||||
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
||||
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
||||
@@ -228,6 +230,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
||||
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc za izlaz",
|
||||
|
||||
"prompt.example.1": "Popravi TODO u bazi koda",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Skift til forrige agent",
|
||||
"command.model.variant.cycle": "Skift tænkeindsats",
|
||||
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
||||
"command.prompt.mode.shell": "Skift til shell-tilstand",
|
||||
"command.prompt.mode.normal": "Skift til prompt-tilstand",
|
||||
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
||||
"command.workspace.toggle": "Skift arbejdsområder",
|
||||
@@ -226,6 +228,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
|
||||
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc for at afslutte",
|
||||
|
||||
"prompt.example.1": "Ret en TODO i koden",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
|
||||
"command.model.variant.cycle": "Denkaufwand wechseln",
|
||||
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
|
||||
"command.prompt.mode.shell": "In den Shell-Modus wechseln",
|
||||
"command.prompt.mode.normal": "In den Prompt-Modus wechseln",
|
||||
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
|
||||
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
|
||||
"command.workspace.toggle": "Arbeitsbereiche umschalten",
|
||||
@@ -215,6 +217,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
|
||||
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc zum Verlassen",
|
||||
"prompt.example.1": "Ein TODO in der Codebasis beheben",
|
||||
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Switch to the previous agent",
|
||||
"command.model.variant.cycle": "Cycle thinking effort",
|
||||
"command.model.variant.cycle.description": "Switch to the next effort level",
|
||||
"command.prompt.mode.shell": "Switch to shell mode",
|
||||
"command.prompt.mode.normal": "Switch to prompt mode",
|
||||
"command.permissions.autoaccept.enable": "Auto-accept edits",
|
||||
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
|
||||
"command.workspace.toggle": "Toggle workspaces",
|
||||
@@ -228,6 +230,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Summarize comments…",
|
||||
"prompt.placeholder.summarizeComment": "Summarize comment…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc to exit",
|
||||
|
||||
"prompt.example.1": "Fix a TODO in the codebase",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Cambiar al agente anterior",
|
||||
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
|
||||
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
|
||||
"command.prompt.mode.shell": "Cambiar al modo Shell",
|
||||
"command.prompt.mode.normal": "Cambiar al modo Prompt",
|
||||
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
|
||||
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
|
||||
"command.workspace.toggle": "Alternar espacios de trabajo",
|
||||
@@ -227,6 +229,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
|
||||
"prompt.placeholder.summarizeComment": "Resumir comentario…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc para salir",
|
||||
|
||||
"prompt.example.1": "Arreglar un TODO en el código",
|
||||
|
||||
@@ -63,6 +63,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Passer à l'agent précédent",
|
||||
"command.model.variant.cycle": "Changer l'effort de réflexion",
|
||||
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
|
||||
"command.prompt.mode.shell": "Passer en mode Shell",
|
||||
"command.prompt.mode.normal": "Passer en mode Prompt",
|
||||
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
|
||||
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
|
||||
"command.workspace.toggle": "Basculer les espaces de travail",
|
||||
@@ -210,6 +212,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
|
||||
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc pour quitter",
|
||||
"prompt.example.1": "Corriger un TODO dans la base de code",
|
||||
"prompt.example.2": "Quelle est la pile technique de ce projet ?",
|
||||
|
||||
@@ -63,6 +63,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "前のエージェントに切り替え",
|
||||
"command.model.variant.cycle": "思考レベルの切り替え",
|
||||
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
|
||||
"command.prompt.mode.shell": "シェルモードに切り替える",
|
||||
"command.prompt.mode.normal": "プロンプトモードに切り替える",
|
||||
"command.permissions.autoaccept.enable": "編集を自動承認",
|
||||
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
|
||||
"command.workspace.toggle": "ワークスペースを切り替え",
|
||||
@@ -209,6 +211,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "コメントを要約…",
|
||||
"prompt.placeholder.summarizeComment": "コメントを要約…",
|
||||
"prompt.mode.shell": "シェル",
|
||||
"prompt.mode.normal": "プロンプト",
|
||||
"prompt.mode.shell.exit": "escで終了",
|
||||
"prompt.example.1": "コードベースのTODOを修正",
|
||||
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "이전 에이전트로 전환",
|
||||
"command.model.variant.cycle": "생각 수준 순환",
|
||||
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
|
||||
"command.prompt.mode.shell": "셸 모드로 전환",
|
||||
"command.prompt.mode.normal": "프롬프트 모드로 전환",
|
||||
"command.permissions.autoaccept.enable": "편집 자동 수락",
|
||||
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
|
||||
"command.workspace.toggle": "작업 공간 전환",
|
||||
@@ -213,6 +215,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "댓글 요약…",
|
||||
"prompt.placeholder.summarizeComment": "댓글 요약…",
|
||||
"prompt.mode.shell": "셸",
|
||||
"prompt.mode.normal": "프롬프트",
|
||||
"prompt.mode.shell.exit": "종료하려면 esc",
|
||||
"prompt.example.1": "코드베이스의 TODO 수정",
|
||||
"prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?",
|
||||
|
||||
@@ -72,6 +72,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Bytt til forrige agent",
|
||||
"command.model.variant.cycle": "Bytt tenkeinnsats",
|
||||
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
|
||||
"command.prompt.mode.shell": "Bytt til Shell-modus",
|
||||
"command.prompt.mode.normal": "Bytt til Prompt-modus",
|
||||
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
|
||||
"command.workspace.toggle": "Veksle arbeidsområder",
|
||||
@@ -230,6 +232,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
|
||||
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "ESC for å avslutte",
|
||||
|
||||
"prompt.example.1": "Fiks en TODO i kodebasen",
|
||||
|
||||
@@ -63,6 +63,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
|
||||
"command.model.variant.cycle": "Przełącz wysiłek myślowy",
|
||||
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
|
||||
"command.prompt.mode.shell": "Przełącz na tryb terminala",
|
||||
"command.prompt.mode.normal": "Przełącz na tryb Prompt",
|
||||
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
|
||||
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
|
||||
"command.workspace.toggle": "Przełącz przestrzenie robocze",
|
||||
@@ -211,6 +213,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
|
||||
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
|
||||
"prompt.mode.shell": "Terminal",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "esc aby wyjść",
|
||||
"prompt.example.1": "Napraw TODO w bazie kodu",
|
||||
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
|
||||
"command.model.variant.cycle": "Цикл режимов мышления",
|
||||
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
|
||||
"command.prompt.mode.shell": "Переключиться в режим оболочки",
|
||||
"command.prompt.mode.normal": "Переключиться в режим промпта",
|
||||
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
|
||||
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
|
||||
"command.workspace.toggle": "Переключить рабочие пространства",
|
||||
@@ -227,6 +229,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
|
||||
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
|
||||
"prompt.mode.shell": "Оболочка",
|
||||
"prompt.mode.normal": "Промпт",
|
||||
"prompt.mode.shell.exit": "esc для выхода",
|
||||
|
||||
"prompt.example.1": "Исправить TODO в коде",
|
||||
|
||||
@@ -69,6 +69,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
|
||||
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
|
||||
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
|
||||
"command.prompt.mode.shell": "สลับไปยังโหมดเชลล์",
|
||||
"command.prompt.mode.normal": "สลับไปยังโหมดพรอมต์",
|
||||
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
|
||||
@@ -227,6 +229,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
|
||||
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
|
||||
"prompt.mode.shell": "เชลล์",
|
||||
"prompt.mode.normal": "พรอมต์",
|
||||
"prompt.mode.shell.exit": "กด esc เพื่อออก",
|
||||
|
||||
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
|
||||
|
||||
@@ -93,6 +93,9 @@ export const dict = {
|
||||
"command.model.variant.cycle": "切换思考强度",
|
||||
"command.model.variant.cycle.description": "切换到下一个强度等级",
|
||||
|
||||
"command.prompt.mode.shell": "切换到 Shell 模式",
|
||||
"command.prompt.mode.normal": "切换到 Prompt 模式",
|
||||
|
||||
"command.permissions.autoaccept.enable": "自动接受编辑",
|
||||
"command.permissions.autoaccept.disable": "停止自动接受编辑",
|
||||
|
||||
@@ -248,6 +251,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "总结评论…",
|
||||
"prompt.placeholder.summarizeComment": "总结该评论…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "按 esc 退出",
|
||||
"prompt.example.1": "修复代码库中的一个 TODO",
|
||||
"prompt.example.2": "这个项目的技术栈是什么?",
|
||||
|
||||
@@ -73,6 +73,8 @@ export const dict = {
|
||||
"command.agent.cycle.reverse.description": "切換到上一個代理程式",
|
||||
"command.model.variant.cycle": "循環思考強度",
|
||||
"command.model.variant.cycle.description": "切換到下一個強度等級",
|
||||
"command.prompt.mode.shell": "切換到 Shell 模式",
|
||||
"command.prompt.mode.normal": "切換到 Prompt 模式",
|
||||
"command.permissions.autoaccept.enable": "自動接受編輯",
|
||||
"command.permissions.autoaccept.disable": "停止自動接受編輯",
|
||||
"command.workspace.toggle": "切換工作區",
|
||||
@@ -227,6 +229,7 @@ export const dict = {
|
||||
"prompt.placeholder.summarizeComments": "摘要評論…",
|
||||
"prompt.placeholder.summarizeComment": "摘要這則評論…",
|
||||
"prompt.mode.shell": "Shell",
|
||||
"prompt.mode.normal": "Prompt",
|
||||
"prompt.mode.shell.exit": "按 esc 退出",
|
||||
|
||||
"prompt.example.1": "修復程式碼庫中的一個 TODO",
|
||||
|
||||
@@ -165,7 +165,7 @@ export function MessageTimeline(props: {
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-4 md:pl-4 md:pr-6": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { BasicTool } from "@opencode-ai/ui/basic-tool"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { QuestionDock } from "@/components/question-dock"
|
||||
import { SessionTodoDock } from "@/components/session-todo-dock"
|
||||
@@ -123,63 +124,79 @@ export function SessionPromptDock(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={props.permissionRequest()} keyed>
|
||||
{(perm) => (
|
||||
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
|
||||
<BasicTool
|
||||
icon="checklist"
|
||||
locked
|
||||
defaultOpen
|
||||
trigger={{
|
||||
title: props.t("notification.permission.title"),
|
||||
subtitle:
|
||||
perm.permission === "doom_loop"
|
||||
? props.t("settings.permissions.tool.doom_loop.title")
|
||||
: perm.permission,
|
||||
}}
|
||||
>
|
||||
<Show when={perm.patterns.length > 0}>
|
||||
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
|
||||
<For each={perm.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={perm.permission === "doom_loop"}>
|
||||
<div class="text-12-regular text-text-weak pb-2 px-3">
|
||||
{props.t("settings.permissions.tool.doom_loop.description")}
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("reject")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("once")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
{(perm) => {
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${perm.permission}.description`
|
||||
const value = props.t(key)
|
||||
if (value === key) return ""
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DockPrompt
|
||||
kind="permission"
|
||||
header={
|
||||
<div data-slot="permission-row" data-variant="header">
|
||||
<span data-slot="permission-icon">
|
||||
<Icon name="warning" size="normal" />
|
||||
</span>
|
||||
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div />
|
||||
<div data-slot="permission-footer-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("reject")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("once")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={toolDescription()}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={perm.patterns.length > 0}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-patterns">
|
||||
<For each={perm.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.blocked}>
|
||||
|
||||
@@ -6,7 +6,10 @@ describe("terminalWriter", () => {
|
||||
const calls: string[] = []
|
||||
const scheduled: VoidFunction[] = []
|
||||
const writer = terminalWriter(
|
||||
(data) => calls.push(data),
|
||||
(data, done) => {
|
||||
calls.push(data)
|
||||
done?.()
|
||||
},
|
||||
(flush) => scheduled.push(flush),
|
||||
)
|
||||
|
||||
@@ -24,10 +27,38 @@ describe("terminalWriter", () => {
|
||||
test("flush is a no-op when empty", () => {
|
||||
const calls: string[] = []
|
||||
const writer = terminalWriter(
|
||||
(data) => calls.push(data),
|
||||
(data, done) => {
|
||||
calls.push(data)
|
||||
done?.()
|
||||
},
|
||||
(flush) => flush(),
|
||||
)
|
||||
writer.flush()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
test("flush waits for pending write completion", () => {
|
||||
const calls: string[] = []
|
||||
let done: VoidFunction | undefined
|
||||
const writer = terminalWriter(
|
||||
(data, finish) => {
|
||||
calls.push(data)
|
||||
done = finish
|
||||
},
|
||||
(flush) => flush(),
|
||||
)
|
||||
|
||||
writer.push("a")
|
||||
|
||||
let settled = false
|
||||
writer.flush(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
expect(calls).toEqual(["a"])
|
||||
expect(settled).toBe(false)
|
||||
|
||||
done?.()
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
export function terminalWriter(
|
||||
write: (data: string) => void,
|
||||
write: (data: string, done?: VoidFunction) => void,
|
||||
schedule: (flush: VoidFunction) => void = queueMicrotask,
|
||||
) {
|
||||
let chunks: string[] | undefined
|
||||
let waits: VoidFunction[] | undefined
|
||||
let scheduled = false
|
||||
let writing = false
|
||||
|
||||
const flush = () => {
|
||||
const settle = () => {
|
||||
if (scheduled || writing || chunks?.length) return
|
||||
const list = waits
|
||||
if (!list?.length) return
|
||||
waits = undefined
|
||||
for (const fn of list) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
if (writing) return
|
||||
scheduled = false
|
||||
const items = chunks
|
||||
if (!items?.length) return
|
||||
if (!items?.length) {
|
||||
settle()
|
||||
return
|
||||
}
|
||||
chunks = undefined
|
||||
write(items.join(""))
|
||||
writing = true
|
||||
write(items.join(""), () => {
|
||||
writing = false
|
||||
if (chunks?.length) {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
schedule(run)
|
||||
return
|
||||
}
|
||||
settle()
|
||||
})
|
||||
}
|
||||
|
||||
const push = (data: string) => {
|
||||
@@ -18,9 +44,21 @@ export function terminalWriter(
|
||||
if (chunks) chunks.push(data)
|
||||
else chunks = [data]
|
||||
|
||||
if (scheduled) return
|
||||
if (scheduled || writing) return
|
||||
scheduled = true
|
||||
schedule(flush)
|
||||
schedule(run)
|
||||
}
|
||||
|
||||
const flush = (done?: VoidFunction) => {
|
||||
if (!scheduled && !writing && !chunks?.length) {
|
||||
done?.()
|
||||
return
|
||||
}
|
||||
if (done) {
|
||||
if (waits) waits.push(done)
|
||||
else waits = [done]
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
return { push, flush }
|
||||
|
||||
@@ -6,13 +6,17 @@ use process_wrap::tokio::ProcessGroup;
|
||||
use process_wrap::tokio::{JobObject, KillOnDrop};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::sync::Arc;
|
||||
use std::{process::Stdio, time::Duration};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::Event;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::{
|
||||
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
|
||||
process::Command,
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::Instrument;
|
||||
|
||||
@@ -34,8 +38,8 @@ pub struct Config {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandEvent {
|
||||
Stdout(Vec<u8>),
|
||||
Stderr(Vec<u8>),
|
||||
Stdout(String),
|
||||
Stderr(String),
|
||||
Error(String),
|
||||
Terminated(TerminatedPayload),
|
||||
}
|
||||
@@ -64,10 +68,11 @@ pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
|
||||
events
|
||||
.fold(String::new(), async |mut config_str, event| {
|
||||
if let CommandEvent::Stdout(stdout) = event
|
||||
&& let Ok(s) = str::from_utf8(&stdout)
|
||||
{
|
||||
config_str += s
|
||||
if let CommandEvent::Stdout(s) = &event {
|
||||
config_str += s.as_str()
|
||||
}
|
||||
if let CommandEvent::Stderr(s) = &event {
|
||||
config_str += s.as_str()
|
||||
}
|
||||
|
||||
config_str
|
||||
@@ -317,9 +322,9 @@ pub fn spawn_command(
|
||||
cmd
|
||||
};
|
||||
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.stdin(Stdio::null());
|
||||
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
@@ -337,32 +342,24 @@ pub fn spawn_command(
|
||||
}
|
||||
|
||||
let mut child = wrap.spawn()?;
|
||||
let stdout = child.stdout().take();
|
||||
let stderr = child.stderr().take();
|
||||
let guard = Arc::new(tokio::sync::RwLock::new(()));
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let (kill_tx, mut kill_rx) = mpsc::channel(1);
|
||||
|
||||
if let Some(stdout) = stdout {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
let stdout = spawn_pipe_reader(
|
||||
tx.clone(),
|
||||
guard.clone(),
|
||||
BufReader::new(child.stdout().take().unwrap()),
|
||||
CommandEvent::Stdout,
|
||||
);
|
||||
let stderr = spawn_pipe_reader(
|
||||
tx.clone(),
|
||||
guard.clone(),
|
||||
BufReader::new(child.stderr().take().unwrap()),
|
||||
CommandEvent::Stderr,
|
||||
);
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::task::spawn(async move {
|
||||
let mut kill_open = true;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
@@ -394,6 +391,9 @@ pub fn spawn_command(
|
||||
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
|
||||
}
|
||||
}
|
||||
|
||||
stdout.abort();
|
||||
stderr.abort();
|
||||
});
|
||||
|
||||
let event_stream = ReceiverStream::new(rx);
|
||||
@@ -404,9 +404,7 @@ pub fn spawn_command(
|
||||
|
||||
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
return status.signal();
|
||||
}
|
||||
return status.signal();
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -442,12 +440,10 @@ pub fn serve(
|
||||
events
|
||||
.for_each(move |event| {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
CommandEvent::Stdout(line) => {
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
CommandEvent::Stderr(line) => {
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
@@ -499,11 +495,7 @@ pub mod sqlite_migration {
|
||||
}
|
||||
|
||||
future::ready(match &event {
|
||||
CommandEvent::Stdout(stdout) => {
|
||||
let Ok(s) = str::from_utf8(stdout) else {
|
||||
return future::ready(None);
|
||||
};
|
||||
|
||||
CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
|
||||
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
||||
if let Ok(progress) = s.parse::<u8>() {
|
||||
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
||||
@@ -522,3 +514,41 @@ pub mod sqlite_migration {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||
tx: mpsc::Sender<CommandEvent>,
|
||||
guard: Arc<tokio::sync::RwLock<()>>,
|
||||
pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
|
||||
wrapper: F,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let _lock = guard.read().await;
|
||||
let reader = BufReader::new(pipe_reader);
|
||||
|
||||
read_line(reader, tx, wrapper).await;
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||
reader: BufReader<impl AsyncBufRead + Unpin>,
|
||||
tx: mpsc::Sender<CommandEvent>,
|
||||
wrapper: F,
|
||||
) {
|
||||
let mut lines = reader.lines();
|
||||
loop {
|
||||
let line = lines.next_line().await;
|
||||
|
||||
match line {
|
||||
Ok(s) => {
|
||||
if let Some(s) = s {
|
||||
let _ = tx.clone().send(wrapper(s)).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let tx_ = tx.clone();
|
||||
let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deduplicated list of plugin-registered auth providers that are not
|
||||
* already present in models.dev, respecting enabled/disabled provider lists.
|
||||
* Pure function with no side effects; safe to test without mocking.
|
||||
*/
|
||||
export function resolvePluginProviders(input: {
|
||||
hooks: Hooks[]
|
||||
existingProviders: Record<string, unknown>
|
||||
disabled: Set<string>
|
||||
enabled?: Set<string>
|
||||
providerNames: Record<string, string | undefined>
|
||||
}): Array<{ id: string; name: string }> {
|
||||
const seen = new Set<string>()
|
||||
const result: Array<{ id: string; name: string }> = []
|
||||
|
||||
for (const hook of input.hooks) {
|
||||
if (!hook.auth) continue
|
||||
const id = hook.auth.provider
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
if (Object.hasOwn(input.existingProviders, id)) continue
|
||||
if (input.disabled.has(id)) continue
|
||||
if (input.enabled && !input.enabled.has(id)) continue
|
||||
result.push({
|
||||
id,
|
||||
name: input.providerNames[id] ?? id,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
@@ -277,6 +309,13 @@ export const AuthLoginCommand = cmd({
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks: await Plugin.list(),
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
@@ -298,6 +337,11 @@ export const AuthLoginCommand = cmd({
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ExportCommand = cmd({
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let sessionID = args.sessionID
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}`)
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
|
||||
@@ -64,12 +64,16 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"→"}</text>
|
||||
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
||||
</box>
|
||||
<Show when={diff()}>
|
||||
<scrollbox height="100%">
|
||||
<scrollbox
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background,
|
||||
foregroundColor: theme.borderActive,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<diff
|
||||
diff={diff()}
|
||||
view={view()}
|
||||
@@ -91,6 +95,11 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
/>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={!diff()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>No diff provided</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -194,76 +203,233 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
</Match>
|
||||
<Match when={store.stage === "permission"}>
|
||||
{(() => {
|
||||
const info = () => {
|
||||
const permission = props.request.permission
|
||||
const data = input()
|
||||
|
||||
if (permission === "edit") {
|
||||
const raw = props.request.metadata?.filepath
|
||||
const filepath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${normalizePath(filepath)}`,
|
||||
body: <EditBody request={props.request} />,
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "read") {
|
||||
const raw = data.filePath
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${normalizePath(filePath)}`,
|
||||
body: (
|
||||
<Show when={filePath}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "glob") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Glob "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "grep") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Grep "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "list") {
|
||||
const raw = data.path
|
||||
const dir = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${normalizePath(dir)}`,
|
||||
body: (
|
||||
<Show when={dir}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "bash") {
|
||||
const title =
|
||||
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title,
|
||||
body: (
|
||||
<Show when={command}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>{"$ " + command}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "task") {
|
||||
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
|
||||
const desc = typeof data.description === "string" ? data.description : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(type)} Task`,
|
||||
body: (
|
||||
<Show when={desc}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>{"◉ " + desc}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "webfetch") {
|
||||
const url = typeof data.url === "string" ? data.url : ""
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
body: (
|
||||
<Show when={url}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"URL: " + url}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "websearch") {
|
||||
const query = typeof data.query === "string" ? data.query : ""
|
||||
return {
|
||||
icon: "◈",
|
||||
title: `Exa Web Search "${query}"`,
|
||||
body: (
|
||||
<Show when={query}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Query: " + query}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "codesearch") {
|
||||
const query = typeof data.query === "string" ? data.query : ""
|
||||
return {
|
||||
icon: "◇",
|
||||
title: `Exa Code Search "${query}"`,
|
||||
body: (
|
||||
<Show when={query}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Query: " + query}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "external_directory") {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const derived =
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = normalizePath(raw)
|
||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${dir}`,
|
||||
body: (
|
||||
<Show when={patterns.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.textMuted}>Patterns</text>
|
||||
<box>
|
||||
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${permission}`,
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const current = info()
|
||||
|
||||
const header = () => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>Permission required</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={theme.text}>{current.title}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
const body = (
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "external_directory"}>
|
||||
{(() => {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const derived =
|
||||
typeof pattern === "string"
|
||||
? pattern.includes("*")
|
||||
? path.dirname(pattern)
|
||||
: pattern
|
||||
: undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = normalizePath(raw)
|
||||
|
||||
return <TextBody icon="←" title={`Access external directory ` + dir} />
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={props.request.permission === "doom_loop"}>
|
||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
header={header()}
|
||||
body={current.body}
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
@@ -372,6 +538,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
|
||||
function Prompt<const T extends Record<string, string>>(props: {
|
||||
title: string
|
||||
header?: JSX.Element
|
||||
body: JSX.Element
|
||||
options: T
|
||||
escapeKey?: keyof T
|
||||
@@ -445,10 +612,19 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
})}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
<Show
|
||||
when={props.header}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box paddingLeft={1} flexShrink={0}>
|
||||
{props.header}
|
||||
</box>
|
||||
</Show>
|
||||
{props.body}
|
||||
</box>
|
||||
<box
|
||||
|
||||
@@ -80,7 +80,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
paddingRight={2}
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox flexGrow={1}>
|
||||
<scrollbox
|
||||
flexGrow={1}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background,
|
||||
foregroundColor: theme.borderActive,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
|
||||
@@ -184,5 +184,6 @@ export const TuiThreadCommand = cmd({
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -137,7 +137,12 @@ export const rpc = {
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
await Instance.disposeAll()
|
||||
await Promise.race([
|
||||
Instance.disposeAll(),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 5000)
|
||||
}),
|
||||
])
|
||||
if (server) server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -149,6 +149,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
return entry.oauthState
|
||||
}
|
||||
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
log.info("invalidating credentials", { mcpName: this.mcpName, type })
|
||||
const entry = await McpAuth.get(this.mcpName)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
await McpAuth.remove(this.mcpName)
|
||||
break
|
||||
case "client":
|
||||
delete entry.clientInfo
|
||||
await McpAuth.set(this.mcpName, entry)
|
||||
break
|
||||
case "tokens":
|
||||
delete entry.tokens
|
||||
await McpAuth.set(this.mcpName, entry)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
@@ -122,7 +122,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
|
||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
|
||||
ModelsDev.refresh()
|
||||
setInterval(
|
||||
async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: query.archived,
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -154,6 +156,24 @@ export namespace Session {
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ProjectInfo = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
worktree: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProjectSummary",
|
||||
})
|
||||
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||
|
||||
export const GlobalInfo = Info.extend({
|
||||
project: ProjectInfo.nullable(),
|
||||
}).meta({
|
||||
ref: "GlobalSession",
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define(
|
||||
"session.created",
|
||||
@@ -544,6 +564,75 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
export function* listGlobal(input?: {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: boolean
|
||||
}) {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
}
|
||||
if (input?.roots) {
|
||||
conditions.push(isNull(SessionTable.parent_id))
|
||||
}
|
||||
if (input?.start) {
|
||||
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
}
|
||||
if (input?.cursor) {
|
||||
conditions.push(lt(SessionTable.time_updated, input.cursor))
|
||||
}
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
if (!input?.archived) {
|
||||
conditions.push(isNull(SessionTable.time_archived))
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 100
|
||||
|
||||
const rows = Database.use((db) => {
|
||||
const query =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
: db.select().from(SessionTable)
|
||||
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
|
||||
})
|
||||
|
||||
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||
const projects = new Map<string, ProjectInfo>()
|
||||
|
||||
if (ids.length > 0) {
|
||||
const items = Database.use((db) =>
|
||||
db
|
||||
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(inArray(ProjectTable.id, ids))
|
||||
.all(),
|
||||
)
|
||||
for (const item of items) {
|
||||
projects.set(item.id, {
|
||||
id: item.id,
|
||||
name: item.name ?? undefined,
|
||||
worktree: item.worktree,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const project = projects.get(row.project_id) ?? null
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
}
|
||||
|
||||
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
||||
const project = Instance.project
|
||||
const rows = Database.use((db) =>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import z from "zod"
|
||||
import * as fs from "fs"
|
||||
import { createReadStream } from "fs"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { createInterface } from "readline"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
@@ -11,7 +13,9 @@ import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MAX_BYTES = 50 * 1024
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
|
||||
export const ReadTool = Tool.define("read", {
|
||||
description: DESCRIPTION,
|
||||
@@ -49,14 +53,18 @@ export const ReadTool = Tool.define("read", {
|
||||
const dir = path.dirname(filepath)
|
||||
const base = path.basename(filepath)
|
||||
|
||||
const dirEntries = fs.readdirSync(dir)
|
||||
const suggestions = dirEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
||||
const suggestions = await fs
|
||||
.readdir(dir)
|
||||
.then((entries) =>
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3)
|
||||
.catch(() => [])
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
||||
@@ -66,12 +74,12 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
|
||||
const dirents = await fs.readdir(filepath, { withFileTypes: true })
|
||||
const entries = await Promise.all(
|
||||
dirents.map(async (dirent) => {
|
||||
if (dirent.isDirectory()) return dirent.name + "/"
|
||||
if (dirent.isSymbolicLink()) {
|
||||
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
||||
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
||||
if (target?.isDirectory()) return dirent.name + "/"
|
||||
}
|
||||
return dirent.name
|
||||
@@ -134,27 +142,53 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
}
|
||||
|
||||
const isBinary = await isBinaryFile(filepath, file)
|
||||
const isBinary = await isBinaryFile(filepath, stat.size)
|
||||
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
||||
|
||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
||||
// ('\r\n') in file as a single line break.
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset ?? 1
|
||||
const start = offset - 1
|
||||
const lines = await file.text().then((text) => text.split("\n"))
|
||||
if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`)
|
||||
|
||||
const raw: string[] = []
|
||||
let bytes = 0
|
||||
let lines = 0
|
||||
let truncatedByBytes = false
|
||||
for (let i = start; i < Math.min(lines.length, start + limit); i++) {
|
||||
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
|
||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true
|
||||
break
|
||||
let hasMoreLines = false
|
||||
try {
|
||||
for await (const text of rl) {
|
||||
lines += 1
|
||||
if (lines <= start) continue
|
||||
|
||||
if (raw.length >= limit) {
|
||||
hasMoreLines = true
|
||||
continue
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true
|
||||
hasMoreLines = true
|
||||
break
|
||||
}
|
||||
|
||||
raw.push(line)
|
||||
bytes += size
|
||||
}
|
||||
raw.push(line)
|
||||
bytes += size
|
||||
} finally {
|
||||
rl.close()
|
||||
stream.destroy()
|
||||
}
|
||||
|
||||
if (lines < offset && !(lines === 0 && offset === 1)) {
|
||||
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
|
||||
}
|
||||
|
||||
const content = raw.map((line, index) => {
|
||||
@@ -165,15 +199,15 @@ export const ReadTool = Tool.define("read", {
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
output += content.join("\n")
|
||||
|
||||
const totalLines = lines.length
|
||||
const totalLines = lines
|
||||
const lastReadLine = offset + raw.length - 1
|
||||
const hasMoreLines = totalLines > lastReadLine
|
||||
const nextOffset = lastReadLine + 1
|
||||
const truncated = hasMoreLines || truncatedByBytes
|
||||
|
||||
if (truncatedByBytes) {
|
||||
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
||||
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||
} else if (hasMoreLines) {
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
||||
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
|
||||
} else {
|
||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||
}
|
||||
@@ -199,7 +233,7 @@ export const ReadTool = Tool.define("read", {
|
||||
},
|
||||
})
|
||||
|
||||
async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
|
||||
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
||||
const ext = path.extname(filepath).toLowerCase()
|
||||
// binary check for common non-text extensions
|
||||
switch (ext) {
|
||||
@@ -236,22 +270,25 @@ async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolea
|
||||
break
|
||||
}
|
||||
|
||||
const stat = await file.stat()
|
||||
const fileSize = stat.size
|
||||
if (fileSize === 0) return false
|
||||
|
||||
const bufferSize = Math.min(4096, fileSize)
|
||||
const buffer = await file.arrayBuffer()
|
||||
if (buffer.byteLength === 0) return false
|
||||
const bytes = new Uint8Array(buffer.slice(0, bufferSize))
|
||||
const fh = await fs.open(filepath, "r")
|
||||
try {
|
||||
const sampleSize = Math.min(4096, fileSize)
|
||||
const bytes = Buffer.alloc(sampleSize)
|
||||
const result = await fh.read(bytes, 0, sampleSize, 0)
|
||||
if (result.bytesRead === 0) return false
|
||||
|
||||
let nonPrintableCount = 0
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] === 0) return true
|
||||
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
||||
nonPrintableCount++
|
||||
let nonPrintableCount = 0
|
||||
for (let i = 0; i < result.bytesRead; i++) {
|
||||
if (bytes[i] === 0) return true
|
||||
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
||||
nonPrintableCount++
|
||||
}
|
||||
}
|
||||
// If >30% non-printable characters, consider it binary
|
||||
return nonPrintableCount / result.bytesRead > 0.3
|
||||
} finally {
|
||||
await fh.close()
|
||||
}
|
||||
// If >30% non-printable characters, consider it binary
|
||||
return nonPrintableCount / bytes.length > 0.3
|
||||
}
|
||||
|
||||
120
packages/opencode/test/cli/plugin-auth-picker.test.ts
Normal file
120
packages/opencode/test/cli/plugin-auth-picker.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
|
||||
function hookWithAuth(provider: string): Hooks {
|
||||
return {
|
||||
auth: {
|
||||
provider,
|
||||
methods: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hookWithoutAuth(): Hooks {
|
||||
return {}
|
||||
}
|
||||
|
||||
describe("resolvePluginProviders", () => {
|
||||
test("returns plugin providers not in models.dev", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||
})
|
||||
|
||||
test("skips providers already in models.dev", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("anthropic")],
|
||||
existingProviders: { anthropic: {} },
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("deduplicates across plugins", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||
})
|
||||
|
||||
test("respects disabled_providers", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(["portkey"]),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("respects enabled_providers when provider is absent", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
enabled: new Set(["anthropic"]),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("includes provider when in enabled set", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
enabled: new Set(["portkey"]),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||
})
|
||||
|
||||
test("resolves name from providerNames", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: { portkey: "Portkey AI" },
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }])
|
||||
})
|
||||
|
||||
test("falls back to id when no name configured", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithAuth("portkey")],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||
})
|
||||
|
||||
test("skips hooks without auth", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||
})
|
||||
|
||||
test("returns empty for no hooks", () => {
|
||||
const result = resolvePluginProviders({
|
||||
hooks: [],
|
||||
existingProviders: {},
|
||||
disabled: new Set(),
|
||||
providerNames: {},
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("Session.listGlobal", () => {
|
||||
test("lists sessions across projects with project metadata", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
const firstSession = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "first-session" }),
|
||||
})
|
||||
const secondSession = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "second-session" }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
expect(ids).toContain(secondSession.id)
|
||||
|
||||
const firstProject = Project.get(firstSession.projectID)
|
||||
const secondProject = Project.get(secondSession.projectID)
|
||||
|
||||
const firstItem = sessions.find((session) => session.id === firstSession.id)
|
||||
const secondItem = sessions.find((session) => session.id === secondSession.id)
|
||||
|
||||
expect(firstItem?.project?.id).toBe(firstProject?.id)
|
||||
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
|
||||
expect(secondItem?.project?.id).toBe(secondProject?.id)
|
||||
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
|
||||
})
|
||||
|
||||
test("excludes archived sessions by default", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const archived = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "archived-session" }),
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
})
|
||||
|
||||
test("supports cursor pagination", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const first = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
expect(ids).not.toContain(second.id)
|
||||
})
|
||||
})
|
||||
@@ -211,8 +211,8 @@ describe("tool.read truncation", () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Output truncated at")
|
||||
expect(result.output).toContain("bytes")
|
||||
expect(result.output).toContain("Output capped at")
|
||||
expect(result.output).toContain("Use offset=")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -230,7 +230,8 @@ describe("tool.read truncation", () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("File has more lines")
|
||||
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||
expect(result.output).toContain("Use offset=11")
|
||||
expect(result.output).toContain("line0")
|
||||
expect(result.output).toContain("line9")
|
||||
expect(result.output).not.toContain("line10")
|
||||
@@ -267,6 +268,10 @@ describe("tool.read truncation", () => {
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
||||
expect(result.output).toContain("10: line10")
|
||||
expect(result.output).toContain("14: line14")
|
||||
expect(result.output).not.toContain("9: line10")
|
||||
expect(result.output).not.toContain("15: line15")
|
||||
expect(result.output).toContain("line10")
|
||||
expect(result.output).toContain("line14")
|
||||
expect(result.output).not.toContain("line0")
|
||||
@@ -293,6 +298,40 @@ describe("tool.read truncation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("allows reading empty file at default offset", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "empty.txt"), "")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).toContain("End of file - total 0 lines")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when offset > 1 for empty file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "empty.txt"), "")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: path.join(tmp.path, "empty.txt"), offset: 2 }, ctx)).rejects.toThrow(
|
||||
"Offset 2 is out of range for this file (0 lines)",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not mark final directory page as truncated", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -324,7 +363,7 @@ describe("tool.read truncation", () => {
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
|
||||
expect(result.output).toContain("...")
|
||||
expect(result.output).toContain("(line truncated to 2000 chars)")
|
||||
expect(result.output.length).toBeLessThan(3000)
|
||||
},
|
||||
})
|
||||
@@ -425,3 +464,40 @@ describe("tool.read loaded instructions", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read binary detection", () => {
|
||||
test("rejects text extension files with null bytes", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
|
||||
await Bun.write(path.join(dir, "null-byte.txt"), bytes)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: path.join(tmp.path, "null-byte.txt") }, ctx)).rejects.toThrow(
|
||||
"Cannot read binary file",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects known binary extensions", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "module.wasm"), "not really wasm")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow(
|
||||
"Cannot read binary file",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
height: 22px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
@@ -129,8 +129,8 @@
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 6px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
@@ -179,6 +179,10 @@
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
21
packages/ui/src/components/dock-prompt.tsx
Normal file
21
packages/ui/src/components/dock-prompt.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
export function DockPrompt(props: {
|
||||
kind: "question" | "permission"
|
||||
header: JSX.Element
|
||||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||
<div data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
</div>
|
||||
<div data-slot={slot("footer")}>{props.footer}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,3 +3,35 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
File tree: show monochrome weak icons by default.
|
||||
On hover, show the original file-type colors.
|
||||
*/
|
||||
[data-component="filetree"] .filetree-icon--mono {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-component="filetree"] .filetree-iconpair {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-component="filetree"] .filetree-iconpair [data-component="file-icon"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-component="filetree"] .filetree-iconpair .filetree-icon--color {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--color {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--mono {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import { createMemo, splitProps } from "solid-js"
|
||||
import { createMemo, splitProps, Show } from "solid-js"
|
||||
import sprite from "./file-icons/sprite.svg"
|
||||
import type { IconName } from "./file-icons/types"
|
||||
|
||||
let filter = 0
|
||||
|
||||
export type FileIconProps = JSX.GSVGAttributes<SVGSVGElement> & {
|
||||
node: { path: string; type: "file" | "directory" }
|
||||
expanded?: boolean
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
export const FileIcon: Component<FileIconProps> = (props) => {
|
||||
const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded"])
|
||||
const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded", "mono"])
|
||||
const name = createMemo(() => chooseIconName(local.node.path, local.node.type, local.expanded || false))
|
||||
const id = `file-icon-mono-${filter++}`
|
||||
return (
|
||||
<svg
|
||||
data-component="file-icon"
|
||||
@@ -20,7 +24,15 @@ export const FileIcon: Component<FileIconProps> = (props) => {
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<use href={`${sprite}#${name()}`} />
|
||||
<Show when={local.mono}>
|
||||
<defs>
|
||||
<filter id={id} color-interpolation-filters="sRGB">
|
||||
<feFlood flood-color="currentColor" result="flood" />
|
||||
<feComposite in="flood" in2="SourceAlpha" operator="in" />
|
||||
</filter>
|
||||
</defs>
|
||||
</Show>
|
||||
<use href={`${sprite}#${name()}`} filter={local.mono ? `url(#${id})` : undefined} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -176,6 +176,10 @@
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const icons = {
|
||||
enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"file-tree": `<path d="M4.58203 16.6693L6.66536 9.58594H17.082M4.58203 16.6693H16.457L18.5404 9.58594H17.082M4.58203 16.6693H2.08203V3.33594H8.33203L9.9987 5.83594H17.082V9.58594" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"file-tree-active": `<path d="M6.66536 9.58594L4.58203 16.6693H16.457L18.5404 9.58594H17.082H6.66536Z" fill="currentColor" fill-opacity="40%"/><path d="M4.58203 16.6693L6.66536 9.58594H17.082M4.58203 16.6693H16.457L18.5404 9.58594H17.082M4.58203 16.6693H2.08203V3.33594H8.33203L9.9987 5.83594H17.082V9.58594" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"file-tree-active": `<path d="M6.66536 9.58594L4.58203 16.6693H16.457L18.5404 9.58594H17.082H6.66536Z" fill="currentColor" fill-opacity="16%"/><path d="M4.58203 16.6693L6.66536 9.58594H17.082M4.58203 16.6693H16.457L18.5404 9.58594H17.082M4.58203 16.6693H2.08203V3.33594H8.33203L9.9987 5.83594H17.082V9.58594" stroke="currentColor" stroke-linecap="round"/>`,
|
||||
"magnifying-glass": `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
|
||||
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -44,10 +44,10 @@ const icons = {
|
||||
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
|
||||
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
|
||||
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-right": `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
|
||||
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -56,7 +56,7 @@ const icons = {
|
||||
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
|
||||
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
|
||||
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
|
||||
"layout-bottom-partial": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-bottom-partial": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"layout-bottom-full": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
|
||||
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -78,6 +78,7 @@ const icons = {
|
||||
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-down-to-line": `<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
|
||||
warning: `<path d="M10 7.91667V11.6667M10 13.7417V13.75M10 2.5L1.875 16.25H18.125L10 2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
|
||||
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
|
||||
|
||||
@@ -745,10 +745,139 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-component="button"] {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="question-prompt"] {
|
||||
[data-component="dock-prompt"][data-kind="permission"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
max-height: 100dvh;
|
||||
|
||||
[data-slot="permission-body"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 12px 0;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
overflow: clip;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
[data-slot="permission-header"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="permission-row"] {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
column-gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
[data-slot="permission-row"][data-variant="header"] {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="permission-icon"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-slot="permission-icon"] [data-component="icon"] {
|
||||
color: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-slot="permission-header-title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="permission-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="permission-hint"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="permission-patterns"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 1px 0 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 14px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="permission-footer"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 32px 8px 8px;
|
||||
background-color: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
overflow: clip;
|
||||
margin-top: -24px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
[data-slot="permission-footer-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
[data-component="button"] {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -117,6 +117,7 @@ function createThrottledValue(getValue: () => string) {
|
||||
createEffect(() => {
|
||||
const next = getValue()
|
||||
const now = Date.now()
|
||||
|
||||
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
@@ -250,6 +251,126 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
||||
}
|
||||
|
||||
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
||||
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
|
||||
|
||||
function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
if (Array.isArray(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
function renderable(part: PartType) {
|
||||
if (part.type === "tool") {
|
||||
if (HIDDEN_TOOLS.has(part.tool)) return false
|
||||
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
|
||||
return true
|
||||
}
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
export function AssistantParts(props: {
|
||||
messages: AssistantMessage[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
working?: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const emptyParts: PartType[] = []
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const keys: string[] = []
|
||||
const items: Record<
|
||||
string,
|
||||
{ type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }
|
||||
> = {}
|
||||
const push = (
|
||||
key: string,
|
||||
item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] },
|
||||
) => {
|
||||
keys.push(key)
|
||||
items[key] = item
|
||||
}
|
||||
|
||||
const parts = props.messages.flatMap((message) =>
|
||||
list(data.store.part?.[message.id], emptyParts)
|
||||
.filter(renderable)
|
||||
.map((part) => ({ message, part })),
|
||||
)
|
||||
|
||||
let start = -1
|
||||
|
||||
const flush = (end: number) => {
|
||||
if (start < 0) return
|
||||
const first = parts[start]
|
||||
const last = parts[end]
|
||||
if (!first || !last) {
|
||||
start = -1
|
||||
return
|
||||
}
|
||||
push(`context:${first.part.id}`, {
|
||||
type: "context",
|
||||
parts: parts
|
||||
.slice(start, end + 1)
|
||||
.map((x) => x.part)
|
||||
.filter((part): part is ToolPart => isContextGroupTool(part)),
|
||||
})
|
||||
start = -1
|
||||
}
|
||||
|
||||
parts.forEach((item, index) => {
|
||||
if (isContextGroupTool(item.part)) {
|
||||
if (start < 0) start = index
|
||||
return
|
||||
}
|
||||
|
||||
flush(index - 1)
|
||||
push(`part:${item.message.id}:${item.part.id}`, { type: "part", part: item.part, message: item.message })
|
||||
})
|
||||
|
||||
flush(parts.length - 1)
|
||||
|
||||
return { keys, items }
|
||||
})
|
||||
|
||||
const last = createMemo(() => grouped().keys.at(-1))
|
||||
|
||||
return (
|
||||
<For each={grouped().keys}>
|
||||
{(key) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
const tail = createMemo(() => last() === key)
|
||||
return (
|
||||
<>
|
||||
<Show when={ctx()}>
|
||||
{(entry) => <ContextToolGroup parts={entry().parts} busy={props.working && tail()} />}
|
||||
</Show>
|
||||
<Show when={part()}>
|
||||
{(entry) => (
|
||||
<Part
|
||||
part={entry().part}
|
||||
message={entry().message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
function isContextGroupTool(part: PartType): part is ToolPart {
|
||||
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
|
||||
@@ -390,6 +511,8 @@ export function AssistantMessageDisplay(props: {
|
||||
}
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
if (!renderable(part)) return
|
||||
|
||||
if (isContextGroupTool(part)) {
|
||||
if (start < 0) start = index
|
||||
return
|
||||
@@ -408,31 +531,43 @@ export function AssistantMessageDisplay(props: {
|
||||
<For each={grouped().keys}>
|
||||
{(key) => {
|
||||
const item = createMemo(() => grouped().items[key])
|
||||
const ctx = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "context") return
|
||||
return value
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const value = item()
|
||||
if (!value) return
|
||||
if (value.type !== "part") return
|
||||
return value
|
||||
})
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(value) => {
|
||||
const entry = value()
|
||||
if (entry.type === "context") return <ContextToolGroup parts={entry.parts} />
|
||||
return (
|
||||
<>
|
||||
<Show when={ctx()}>{(entry) => <ContextToolGroup parts={entry().parts} />}</Show>
|
||||
<Show when={part()}>
|
||||
{(entry) => (
|
||||
<Part
|
||||
part={entry.part}
|
||||
part={entry().part}
|
||||
message={props.message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextToolGroup(props: { parts: ToolPart[] }) {
|
||||
function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const pending = createMemo(() =>
|
||||
props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||
const pending = createMemo(
|
||||
() =>
|
||||
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||
)
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
const details = createMemo(() => summary().join(", "))
|
||||
@@ -445,7 +580,7 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
|
||||
when={pending()}
|
||||
fallback={
|
||||
<span data-slot="context-tool-group-title">
|
||||
<span data-slot="context-tool-group-label">Gathered context</span>
|
||||
<span data-slot="context-tool-group-label">{i18n.t("ui.sessionTurn.status.gatheredContext")}</span>
|
||||
<Show when={details().length}>
|
||||
<span data-slot="context-tool-group-summary">{details()}</span>
|
||||
</Show>
|
||||
@@ -891,13 +1026,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
<Show when={showPermission() && permission()}>
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-actions">
|
||||
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
|
||||
<Button variant="ghost" size="normal" onClick={() => respond("reject")}>
|
||||
{i18n.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="small" onClick={() => respond("always")}>
|
||||
<Button variant="secondary" size="normal" onClick={() => respond("always")}>
|
||||
{i18n.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={() => respond("once")}>
|
||||
<Button variant="primary" size="normal" onClick={() => respond("once")}>
|
||||
{i18n.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,85 +1,121 @@
|
||||
[data-component="radio-group"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
--radio-group-height: 28px;
|
||||
--radio-group-gap: 4px;
|
||||
--radio-group-padding: 2px;
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
[data-slot="radio-group-wrapper"] {
|
||||
all: unset;
|
||||
background-color: var(--surface-base);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
background-color: var(--surface-inset-base);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
display: inline-flex;
|
||||
height: var(--radio-group-height);
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&[data-fill] [data-slot="radio-group-wrapper"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-items"] {
|
||||
display: inline-flex;
|
||||
list-style: none;
|
||||
flex-direction: row;
|
||||
gap: var(--radio-group-gap);
|
||||
height: 100%;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&[data-fill] [data-slot="radio-group-items"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-indicator"] {
|
||||
background: var(--button-secondary-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
content: "";
|
||||
opacity: var(--indicator-opacity, 1);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
opacity 200ms ease-out,
|
||||
box-shadow 100ms ease-in-out,
|
||||
width 150ms ease,
|
||||
height 150ms ease,
|
||||
transform 150ms ease;
|
||||
width 200ms ease-out,
|
||||
height 200ms ease-out,
|
||||
transform 200ms ease-out;
|
||||
will-change: transform;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item"] {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Separator between items */
|
||||
[data-slot="radio-group-item"]:not(:first-of-type)::before {
|
||||
background: var(--border-weak-base);
|
||||
border-radius: var(--radius-xs);
|
||||
content: "";
|
||||
inset: 6px 0;
|
||||
position: absolute;
|
||||
transition: opacity 150ms ease;
|
||||
width: 1px;
|
||||
transform: translateX(-0.5px);
|
||||
&[data-fill] [data-slot="radio-group-item"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Hide separator when item or previous item is checked */
|
||||
[data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])::before,
|
||||
[data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])
|
||||
+ [data-slot="radio-group-item"]::before {
|
||||
opacity: 0;
|
||||
[data-slot="radio-group-item-input"] {
|
||||
border-width: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item-label"] {
|
||||
color: var(--text-weak);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: calc(var(--spacing) * 1);
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
padding: 6px 12px;
|
||||
place-content: center;
|
||||
padding: var(--radio-group-padding);
|
||||
position: relative;
|
||||
transition-duration: 150ms;
|
||||
transition-property: color, opacity;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition:
|
||||
color 200ms ease-out,
|
||||
opacity 200ms ease-out;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item-input"] {
|
||||
all: unset;
|
||||
[data-slot="radio-group-item-control"] {
|
||||
align-items: center;
|
||||
border-radius: var(--radius-xs);
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: var(--radio-group-control-padding, 0 10px);
|
||||
transition: background-color 200ms ease-out;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-pad="none"] {
|
||||
--radio-group-control-padding: 0;
|
||||
}
|
||||
|
||||
&[data-pad="normal"] {
|
||||
--radio-group-control-padding: 0 10px;
|
||||
}
|
||||
|
||||
/* Checked state */
|
||||
@@ -87,28 +123,26 @@
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Hover state: match the inset background (adds subtle density) */
|
||||
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
|
||||
+ [data-slot="radio-group-item-label"]:hover
|
||||
[data-slot="radio-group-item-control"] {
|
||||
background-color: var(--surface-inset-base);
|
||||
}
|
||||
|
||||
/* Do not overlay hover on the active segment */
|
||||
[data-slot="radio-group-item-input"][data-checked]
|
||||
+ [data-slot="radio-group-item-label"]:hover
|
||||
[data-slot="radio-group-item-control"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
[data-slot="radio-group-item-input"][data-disabled] + [data-slot="radio-group-item-label"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Hover state for unchecked, enabled items */
|
||||
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + [data-slot="radio-group-item-label"] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
|
||||
+ [data-slot="radio-group-item-label"]:hover {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
|
||||
+ [data-slot="radio-group-item-label"]:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Focus state */
|
||||
[data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
|
||||
[data-slot="radio-group-indicator"] {
|
||||
@@ -126,27 +160,23 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
|
||||
height: 1px;
|
||||
width: auto;
|
||||
inset: 0 6px;
|
||||
transform: translateY(-0.5px);
|
||||
}
|
||||
|
||||
/* Small size variant */
|
||||
&[data-size="small"] {
|
||||
--radio-group-height: 24px;
|
||||
--radio-group-gap: 3px;
|
||||
--radio-group-padding: 2px;
|
||||
|
||||
[data-slot="radio-group-item-label"] {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item"]:not(:first-of-type)::before {
|
||||
inset: 4px 0;
|
||||
[data-slot="radio-group-item-control"] {
|
||||
padding: var(--radio-group-control-padding, 0 8px);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
|
||||
inset: 0 4px;
|
||||
}
|
||||
&[data-size="small"][data-pad="normal"] {
|
||||
--radio-group-control-padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Disabled root state */
|
||||
|
||||
@@ -15,6 +15,8 @@ export type RadioGroupProps<T> = Omit<
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
size?: "small" | "medium"
|
||||
fill?: boolean
|
||||
pad?: "none" | "normal"
|
||||
}
|
||||
|
||||
export function RadioGroup<T>(props: RadioGroupProps<T>) {
|
||||
@@ -28,6 +30,8 @@ export function RadioGroup<T>(props: RadioGroupProps<T>) {
|
||||
"label",
|
||||
"onSelect",
|
||||
"size",
|
||||
"fill",
|
||||
"pad",
|
||||
])
|
||||
|
||||
const getValue = (item: T): string => {
|
||||
@@ -49,6 +53,8 @@ export function RadioGroup<T>(props: RadioGroupProps<T>) {
|
||||
{...others}
|
||||
data-component="radio-group"
|
||||
data-size={local.size ?? "medium"}
|
||||
data-fill={local.fill ? "" : undefined}
|
||||
data-pad={local.pad ?? "normal"}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
@@ -62,9 +68,11 @@ export function RadioGroup<T>(props: RadioGroupProps<T>) {
|
||||
<div role="presentation" data-slot="radio-group-items">
|
||||
<For each={local.options}>
|
||||
{(option) => (
|
||||
<Kobalte.Item value={getValue(option)} data-slot="radio-group-item">
|
||||
<Kobalte.Item value={getValue(option)} data-slot="radio-group-item" data-value={getValue(option)}>
|
||||
<Kobalte.ItemInput data-slot="radio-group-item-input" />
|
||||
<Kobalte.ItemLabel data-slot="radio-group-item-label">{getLabel(option)}</Kobalte.ItemLabel>
|
||||
<Kobalte.ItemLabel data-slot="radio-group-item-label">
|
||||
<span data-slot="radio-group-item-control">{getLabel(option)}</span>
|
||||
</Kobalte.ItemLabel>
|
||||
</Kobalte.Item>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo, createSignal, For, ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Message } from "./message-part"
|
||||
import { AssistantParts, Message } from "./message-part"
|
||||
import { Card } from "./card"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
@@ -91,13 +91,6 @@ function visible(part: PartType) {
|
||||
return false
|
||||
}
|
||||
|
||||
function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string | null }) {
|
||||
const data = useData()
|
||||
const emptyParts: PartType[] = []
|
||||
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
|
||||
return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
|
||||
}
|
||||
|
||||
export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
@@ -237,8 +230,7 @@ export function SessionTurn(
|
||||
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
|
||||
|
||||
const assistantCopyPartID = createMemo(() => {
|
||||
if (!isLastUserMessage()) return null
|
||||
if (status().type !== "idle") return null
|
||||
if (working()) return null
|
||||
return showAssistantCopyPartID() ?? null
|
||||
})
|
||||
const assistantVisible = createMemo(() =>
|
||||
@@ -281,14 +273,11 @@ export function SessionTurn(
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageItem
|
||||
message={assistantMessage}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
working={working()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={edited() > 0}>
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "تفويض العمل",
|
||||
"ui.sessionTurn.status.planning": "تخطيط الخطوات التالية",
|
||||
"ui.sessionTurn.status.gatheringContext": "جمع السياق",
|
||||
"ui.sessionTurn.status.gatheringContext": "استكشاف...",
|
||||
"ui.sessionTurn.status.gatheredContext": "تم الاستكشاف",
|
||||
"ui.sessionTurn.status.searchingCodebase": "البحث في قاعدة التعليمات البرمجية",
|
||||
"ui.sessionTurn.status.searchingWeb": "البحث في الويب",
|
||||
"ui.sessionTurn.status.makingEdits": "إجراء تعديلات",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegando trabalho",
|
||||
"ui.sessionTurn.status.planning": "Planejando próximos passos",
|
||||
"ui.sessionTurn.status.gatheringContext": "Coletando contexto",
|
||||
"ui.sessionTurn.status.gatheringContext": "Explorando...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Explorado",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Pesquisando no código",
|
||||
"ui.sessionTurn.status.searchingWeb": "Pesquisando na web",
|
||||
"ui.sessionTurn.status.makingEdits": "Fazendo edições",
|
||||
|
||||
@@ -37,7 +37,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegiranje posla",
|
||||
"ui.sessionTurn.status.planning": "Planiranje sljedećih koraka",
|
||||
"ui.sessionTurn.status.gatheringContext": "Prikupljanje konteksta",
|
||||
"ui.sessionTurn.status.gatheringContext": "Istraživanje...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Istraženo",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Pretraživanje baze koda",
|
||||
"ui.sessionTurn.status.searchingWeb": "Pretraživanje weba",
|
||||
"ui.sessionTurn.status.makingEdits": "Pravljenje izmjena",
|
||||
|
||||
@@ -32,7 +32,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegerer arbejde",
|
||||
"ui.sessionTurn.status.planning": "Planlægger næste trin",
|
||||
"ui.sessionTurn.status.gatheringContext": "Indsamler kontekst",
|
||||
"ui.sessionTurn.status.gatheringContext": "Udforsker...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Udforsket",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Søger i koden",
|
||||
"ui.sessionTurn.status.searchingWeb": "Søger på nettet",
|
||||
"ui.sessionTurn.status.makingEdits": "Laver ændringer",
|
||||
|
||||
@@ -36,7 +36,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Arbeit delegieren",
|
||||
"ui.sessionTurn.status.planning": "Nächste Schritte planen",
|
||||
"ui.sessionTurn.status.gatheringContext": "Kontext sammeln",
|
||||
"ui.sessionTurn.status.gatheringContext": "Erkunden...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Erkundet",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Codebasis durchsuchen",
|
||||
"ui.sessionTurn.status.searchingWeb": "Web durchsuchen",
|
||||
"ui.sessionTurn.status.makingEdits": "Änderungen vornehmen",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegating work",
|
||||
"ui.sessionTurn.status.planning": "Planning next steps",
|
||||
"ui.sessionTurn.status.gatheringContext": "Gathering context",
|
||||
"ui.sessionTurn.status.gatheringContext": "Exploring...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Explored",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Searching the codebase",
|
||||
"ui.sessionTurn.status.searchingWeb": "Searching the web",
|
||||
"ui.sessionTurn.status.makingEdits": "Making edits",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegando trabajo",
|
||||
"ui.sessionTurn.status.planning": "Planificando siguientes pasos",
|
||||
"ui.sessionTurn.status.gatheringContext": "Recopilando contexto",
|
||||
"ui.sessionTurn.status.gatheringContext": "Explorando...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Explorado",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Buscando en la base de código",
|
||||
"ui.sessionTurn.status.searchingWeb": "Buscando en la web",
|
||||
"ui.sessionTurn.status.makingEdits": "Realizando ediciones",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Délégation du travail",
|
||||
"ui.sessionTurn.status.planning": "Planification des prochaines étapes",
|
||||
"ui.sessionTurn.status.gatheringContext": "Collecte du contexte",
|
||||
"ui.sessionTurn.status.gatheringContext": "Exploration...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Exploré",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Recherche dans la base de code",
|
||||
"ui.sessionTurn.status.searchingWeb": "Recherche sur le web",
|
||||
"ui.sessionTurn.status.makingEdits": "Application des modifications",
|
||||
|
||||
@@ -32,7 +32,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "作業を委任中",
|
||||
"ui.sessionTurn.status.planning": "次のステップを計画中",
|
||||
"ui.sessionTurn.status.gatheringContext": "コンテキストを収集中",
|
||||
"ui.sessionTurn.status.gatheringContext": "探索中...",
|
||||
"ui.sessionTurn.status.gatheredContext": "探索済み",
|
||||
"ui.sessionTurn.status.searchingCodebase": "コードベースを検索中",
|
||||
"ui.sessionTurn.status.searchingWeb": "ウェブを検索中",
|
||||
"ui.sessionTurn.status.makingEdits": "編集を実行中",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "작업 위임 중",
|
||||
"ui.sessionTurn.status.planning": "다음 단계 계획 중",
|
||||
"ui.sessionTurn.status.gatheringContext": "컨텍스트 수집 중",
|
||||
"ui.sessionTurn.status.gatheringContext": "탐색 중...",
|
||||
"ui.sessionTurn.status.gatheredContext": "탐색됨",
|
||||
"ui.sessionTurn.status.searchingCodebase": "코드베이스 검색 중",
|
||||
"ui.sessionTurn.status.searchingWeb": "웹 검색 중",
|
||||
"ui.sessionTurn.status.makingEdits": "편집 수행 중",
|
||||
|
||||
@@ -36,7 +36,8 @@ export const dict: Record<Keys, string> = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegerer arbeid",
|
||||
"ui.sessionTurn.status.planning": "Planlegger neste trinn",
|
||||
"ui.sessionTurn.status.gatheringContext": "Samler inn kontekst",
|
||||
"ui.sessionTurn.status.gatheringContext": "Utforsker...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Utforsket",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Søker i kodebasen",
|
||||
"ui.sessionTurn.status.searchingWeb": "Søker på nettet",
|
||||
"ui.sessionTurn.status.makingEdits": "Gjør endringer",
|
||||
|
||||
@@ -32,7 +32,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Delegowanie pracy",
|
||||
"ui.sessionTurn.status.planning": "Planowanie kolejnych kroków",
|
||||
"ui.sessionTurn.status.gatheringContext": "Zbieranie kontekstu",
|
||||
"ui.sessionTurn.status.gatheringContext": "Eksplorowanie...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Wyeksplorowano",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Przeszukiwanie bazy kodu",
|
||||
"ui.sessionTurn.status.searchingWeb": "Przeszukiwanie sieci",
|
||||
"ui.sessionTurn.status.makingEdits": "Wprowadzanie zmian",
|
||||
|
||||
@@ -32,7 +32,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "Делегирование работы",
|
||||
"ui.sessionTurn.status.planning": "Планирование следующих шагов",
|
||||
"ui.sessionTurn.status.gatheringContext": "Сбор контекста",
|
||||
"ui.sessionTurn.status.gatheringContext": "Исследование...",
|
||||
"ui.sessionTurn.status.gatheredContext": "Исследовано",
|
||||
"ui.sessionTurn.status.searchingCodebase": "Поиск в кодовой базе",
|
||||
"ui.sessionTurn.status.searchingWeb": "Поиск в интернете",
|
||||
"ui.sessionTurn.status.makingEdits": "Внесение изменений",
|
||||
|
||||
@@ -33,7 +33,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "มอบหมายงาน",
|
||||
"ui.sessionTurn.status.planning": "วางแผนขั้นตอนถัดไป",
|
||||
"ui.sessionTurn.status.gatheringContext": "รวบรวมบริบท",
|
||||
"ui.sessionTurn.status.gatheringContext": "กำลังสำรวจ...",
|
||||
"ui.sessionTurn.status.gatheredContext": "สำรวจแล้ว",
|
||||
"ui.sessionTurn.status.searchingCodebase": "กำลังค้นหาโค้ดเบส",
|
||||
"ui.sessionTurn.status.searchingWeb": "กำลังค้นหาบนเว็บ",
|
||||
"ui.sessionTurn.status.makingEdits": "กำลังแก้ไข",
|
||||
|
||||
@@ -37,7 +37,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "正在委派工作",
|
||||
"ui.sessionTurn.status.planning": "正在规划下一步",
|
||||
"ui.sessionTurn.status.gatheringContext": "正在收集上下文",
|
||||
"ui.sessionTurn.status.gatheringContext": "正在探索...",
|
||||
"ui.sessionTurn.status.gatheredContext": "已探索",
|
||||
"ui.sessionTurn.status.searchingCodebase": "正在搜索代码库",
|
||||
"ui.sessionTurn.status.searchingWeb": "正在搜索网页",
|
||||
"ui.sessionTurn.status.makingEdits": "正在修改",
|
||||
|
||||
@@ -37,7 +37,8 @@ export const dict = {
|
||||
|
||||
"ui.sessionTurn.status.delegating": "正在委派工作",
|
||||
"ui.sessionTurn.status.planning": "正在規劃下一步",
|
||||
"ui.sessionTurn.status.gatheringContext": "正在收集上下文",
|
||||
"ui.sessionTurn.status.gatheringContext": "正在探索...",
|
||||
"ui.sessionTurn.status.gatheredContext": "已探索",
|
||||
"ui.sessionTurn.status.searchingCodebase": "正在搜尋程式碼庫",
|
||||
"ui.sessionTurn.status.searchingWeb": "正在搜尋網頁",
|
||||
"ui.sessionTurn.status.makingEdits": "正在修改",
|
||||
|
||||
@@ -19,12 +19,35 @@ export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
fileGap: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = container.closest("[data-component='session-review']")
|
||||
if (root instanceof HTMLElement) {
|
||||
const content = root.querySelector("[data-slot='session-review-container']")
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root: review,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
--icon-success-base: var(--apple-light-7);
|
||||
--icon-success-hover: var(--apple-light-8);
|
||||
--icon-success-active: var(--apple-light-11);
|
||||
--icon-warning-base: var(--amber-light-7);
|
||||
--icon-warning-base: var(--amber-light-9);
|
||||
--icon-warning-hover: var(--amber-light-8);
|
||||
--icon-warning-active: var(--amber-light-11);
|
||||
--icon-critical-base: var(--ember-light-10);
|
||||
@@ -287,7 +287,7 @@
|
||||
--icon-diff-add-active: var(--mint-light-12);
|
||||
--icon-diff-delete-base: var(--ember-light-10);
|
||||
--icon-diff-delete-hover: var(--ember-light-11);
|
||||
--icon-diff-modified-base: var(--icon-warning-base);
|
||||
--icon-diff-modified-base: #ff8c00;
|
||||
--syntax-comment: var(--text-weak);
|
||||
--syntax-regexp: var(--text-base);
|
||||
--syntax-string: #006656;
|
||||
@@ -507,7 +507,7 @@
|
||||
--icon-strong-disabled: var(--smoke-dark-8);
|
||||
--icon-strong-focus: #fdfcfc;
|
||||
--icon-brand-base: var(--white);
|
||||
--icon-interactive-base: var(--cobalt-dark-9);
|
||||
--icon-interactive-base: var(--cobalt-dark-11);
|
||||
--icon-success-base: var(--apple-dark-7);
|
||||
--icon-success-hover: var(--apple-dark-8);
|
||||
--icon-success-active: var(--apple-dark-11);
|
||||
@@ -545,7 +545,7 @@
|
||||
--icon-diff-add-active: var(--mint-dark-11);
|
||||
--icon-diff-delete-base: var(--ember-dark-9);
|
||||
--icon-diff-delete-hover: var(--ember-dark-10);
|
||||
--icon-diff-modified-base: var(--icon-warning-base);
|
||||
--icon-diff-modified-base: #ffba92;
|
||||
--syntax-comment: var(--text-weak);
|
||||
--syntax-regexp: var(--text-base);
|
||||
--syntax-string: #00ceb9;
|
||||
|
||||
@@ -240,7 +240,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
|
||||
tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11]
|
||||
tokens["icon-diff-delete-base"] = diffDelete[isDark ? 8 : 9]
|
||||
tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 9 : 10]
|
||||
tokens["icon-diff-modified-base"] = tokens["icon-warning-base"]
|
||||
tokens["icon-diff-modified-base"] = isDark ? "#ffba92" : "#FF8C00"
|
||||
|
||||
tokens["syntax-comment"] = "var(--text-weak)"
|
||||
tokens["syntax-regexp"] = "var(--text-base)"
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
"icon-strong-disabled": "var(--smoke-dark-8)",
|
||||
"icon-strong-focus": "#fdfcfc",
|
||||
"icon-brand-base": "var(--white)",
|
||||
"icon-interactive-base": "var(--cobalt-dark-9)",
|
||||
"icon-interactive-base": "var(--cobalt-dark-11)",
|
||||
"icon-success-base": "var(--apple-dark-9)",
|
||||
"icon-success-hover": "var(--apple-dark-10)",
|
||||
"icon-success-active": "var(--apple-dark-11)",
|
||||
|
||||
@@ -73,13 +73,14 @@ You can also access our models through the following API endpoints.
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -131,16 +132,18 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
|
||||
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
|
||||
79
script/duplicate-issue.ts
Normal file
79
script/duplicate-issue.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { createOpencode } from "@opencode-ai/sdk"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
file: { type: "string", short: "f" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/duplicate-issue.ts [options] <message>
|
||||
|
||||
Options:
|
||||
-f, --file <path> File to attach to the prompt
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details"
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const message = positionals.join(" ")
|
||||
if (!message) {
|
||||
console.error("Error: message is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const opencode = await createOpencode({ port: 0 })
|
||||
|
||||
try {
|
||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }> =
|
||||
[]
|
||||
|
||||
if (values.file) {
|
||||
const resolved = path.resolve(process.cwd(), values.file)
|
||||
const file = Bun.file(resolved)
|
||||
if (!(await file.exists())) {
|
||||
console.error(`Error: file not found: ${values.file}`)
|
||||
process.exit(1)
|
||||
}
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolved).href,
|
||||
filename: path.basename(resolved),
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
|
||||
parts.push({ type: "text", text: message })
|
||||
|
||||
const session = await opencode.client.session.create()
|
||||
const result = await opencode.client.session
|
||||
.prompt({
|
||||
path: { id: session.data!.id },
|
||||
body: {
|
||||
agent: "duplicate-issue",
|
||||
parts,
|
||||
},
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
.then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? "")
|
||||
|
||||
console.log(result.trim())
|
||||
} finally {
|
||||
opencode.server.close()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,234 +0,0 @@
|
||||
From 90904222b6f8c86a6d0a8ebed9661950f632a4e8 Mon Sep 17 00:00:00 2001
|
||||
From: OpenCode Bot <opencode@sst.dev>
|
||||
Date: Wed, 11 Feb 2026 18:44:27 +0000
|
||||
Subject: [PATCH] add square logo variants to brand page
|
||||
|
||||
---
|
||||
.../asset/brand/opencode-logo-dark-square.png | Bin 0 -> 697 bytes
|
||||
.../asset/brand/opencode-logo-dark-square.svg | 18 ++++++
|
||||
.../brand/opencode-logo-light-square.png | Bin 0 -> 697 bytes
|
||||
.../brand/opencode-logo-light-square.svg | 18 ++++++
|
||||
.../preview-opencode-logo-dark-square.png | Bin 0 -> 1477 bytes
|
||||
.../preview-opencode-logo-light-square.png | Bin 0 -> 1467 bytes
|
||||
.../console/app/src/routes/brand/index.tsx | 60 ++++++++++++++++++
|
||||
7 files changed, 96 insertions(+)
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-dark-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-light-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
create mode 100644 packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png
|
||||
create mode 100644 packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark-square.png b/packages/console/app/src/asset/brand/opencode-logo-dark-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..673c7e3a20f917fae56719ed1c35b4614ecd5f53
|
||||
GIT binary patch
|
||||
literal 697
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV2S`?CT5_>VU7ZSAjOjI=<CS9u(6-}Pa-RjuaN8!
|
||||
z<jcTNrN+R}(89p*3n<j^f`OsbfPvvv0t1893<d`Af;qbaZGaLy0X`wFK>FjGH{Nb;
|
||||
zK*kCWpQS*Gu_VYZn8D%MjWiG^$=lt9p@UV{1IXbl@Q5sCU=ULUVMfm&l@CBc_7YED
|
||||
zSN2y-+(O#2cjOjy1NC%xx;TbZ+<JS?ke5M0fMtVmr)P-K<_$G=ESI!0Hc3x^;^R#P
|
||||
z@VYyUYYSCCTI00A1HzVGb*Dn;c)vb-jcFpozT1*PW*Wd~QY~?fC`m~yNwrEYN(E93
|
||||
zMg~S^x&}tNhK3=A7FH&PRz^nJ1_o9J26?+;7NKa!%}>cptHiBA{`nI*paup{S3j3^
|
||||
HP6<r_YMlsn
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
new file mode 100644
|
||||
index 0000000..6a67f62
|
||||
--- /dev/null
|
||||
+++ b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg
|
||||
@@ -0,0 +1,18 @@
|
||||
+<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+<g transform="translate(30, 0)">
|
||||
+<g clip-path="url(#clip0_1401_86283)">
|
||||
+<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
+<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
+</mask>
|
||||
+<g mask="url(#mask0_1401_86283)">
|
||||
+<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
+<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
+</g>
|
||||
+</g>
|
||||
+</g>
|
||||
+<defs>
|
||||
+<clipPath id="clip0_1401_86283">
|
||||
+<rect width="240" height="300" fill="white"/>
|
||||
+</clipPath>
|
||||
+</defs>
|
||||
+</svg>
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-light-square.png b/packages/console/app/src/asset/brand/opencode-logo-light-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..5c710474abc4668504cb9678da1de6ad33458af9
|
||||
GIT binary patch
|
||||
literal 697
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV2S`?CT5_>VU7ZSAjOjI=<CS9u(6-}Pa-RjuaN8!
|
||||
z<jcTNrN+R}(89p*3n<j^f`OsbfPvvv0t1893<d`Af;qbaZGaLy0X`wFKw42w?)<s4
|
||||
zK*kyVm4AQ~V@Z%-FoVOh8)+a;lDE4HLkFv@2av;A;1OBOz#ygy!i=6lDj$G?>?NMQ
|
||||
zuI#UvxP`Q3@5n9a2I}eXba4!+xb^m&Auof10LupBPR|gd%^Pa$ST1R0Y?7Y-#K)To
|
||||
z;B|Kx*A}XPw8m+J2ZSxX>Q05w@qT^w8q-9EeYYip%rt<}q*~${QIe8al4_M)lnSI6
|
||||
zj0}v-bPbGj4GlvKEv!rot&EJc4GgRd4DxoxEJD$co1c=IR*74K{PQPrKn)C@u6{1-
|
||||
HoD!M<*)j+`
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/opencode-logo-light-square.svg b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
new file mode 100644
|
||||
index 0000000..a738ad8
|
||||
--- /dev/null
|
||||
+++ b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg
|
||||
@@ -0,0 +1,18 @@
|
||||
+<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+<g transform="translate(30, 0)">
|
||||
+<g clip-path="url(#clip0_1401_86274)">
|
||||
+<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
+<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
+</mask>
|
||||
+<g mask="url(#mask0_1401_86274)">
|
||||
+<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
+<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
+</g>
|
||||
+</g>
|
||||
+</g>
|
||||
+<defs>
|
||||
+<clipPath id="clip0_1401_86274">
|
||||
+<rect width="240" height="300" fill="white"/>
|
||||
+</clipPath>
|
||||
+</defs>
|
||||
+</svg>
|
||||
diff --git a/packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png b/packages/console/app/src/asset/brand/preview-opencode-logo-dark-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..604ad7aa7a87c71d4a3972f18da4044e53f745fe
|
||||
GIT binary patch
|
||||
literal 1477
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$<hK>E)e-c@Ne1&9>
|
||||
zAYTTCDm4a%h86~fUqGRT7Yq!g1`G_Z5*Qe)W-u^_7tGleXakf83GfMV1=1hiyun28
|
||||
zU%gs1cdnz2jklZIqq}#b!@|5=U4d+%%7=IEM1_VzxRD_to-Qsx1sTc7KrJ6$zXlo@
|
||||
zFuxY$Hi42LzhDLii5Gug>4^i8#NSshUI=lk@B-x+lf2zs7&=&GJ%Aj}0*}aI1_m)z
|
||||
z5N7lYQuzQBWH0gbb!C6W#4V()m9m%>=ouDUPZ!6Kid%0la{4g^2(UVS^9^9Ux#z#*
|
||||
zUM9{NA)j{pe@M$Rs$BIl=U=S8$$a1WzrRdo{gl+0z}h6r5vC9^6c`^Bx}VO;+bO}5
|
||||
zvNP)ZgKMjwCMdj@`|<2Y_0Pt}1ZL)gY-~-uJS@@@9A*XrISC3k4mfNWo)RazbGPm0
|
||||
z-*LChSmMOL4?kJISKi=fE3%ne|KQy6WQCxi5SK$J*`YY~T7$y$*OKq5Bzd2d?Vs~N
|
||||
z@B8fph5p+U-+hzde)4_q^MCH|=dNhx2_G663hFce=0A)TEv>6k&v^?1%NErV*NBpo
|
||||
z#FA92<f2p{#b9J$WTtCiq-$sxVrXGyVr*q(scm3jWnhruaU&H)LvDUbW?Cg~4U(b>
|
||||
RH-Q=$JYD@<);T3K0RY|)#{mEU
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png b/packages/console/app/src/asset/brand/preview-opencode-logo-light-square.png
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..3964d8528440323730053e56f9d957539937b99d
|
||||
GIT binary patch
|
||||
literal 1467
|
||||
zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9LzwG?TN?!0V$SrM_)$<hK>E)e-c@Ne1&9>
|
||||
zAYTTCDm4a%h86~fUqGRT7Yq!g1`G_Z5*Qe)W-u^_7tGleXakh+3-AeX1=5Oga+pX-
|
||||
zUcR`n@W%CP=g*zh*VWm(dpD2)RHh&+YpAEUZ|@!;cmKY<7tWv4*U?$OZY@wf(5P=q
|
||||
zw@e07{3Stt!3+!%FaEyL69*!Rzpq|wUamVGD8-oM?e4<R!7A$k<Zu>vL>4nJh^c}w
|
||||
zqi2xH2cRH(iKnkC`zt1HA#JUc#jHSIuvmGzIEGZ*dV8@wmnlGi)v?HEMncg4sk@lD
|
||||
zIaT_<lxtU%8_(QxrNqqsn)KQ9%h&DxCN=JfRETIyU~LlR2pb9vn-fWv%cJ$!m?PI+
|
||||
zGv4tv%TnURa`rlle{ppQ3O5coY-nsuU}iqZ#@58k!y+xgVP+tZGdv|e#VtQu_MLV6
|
||||
z?L5Ea#y9-;OWOY?C_F#SfA^^jN9a(9$sv^JP@HNicjD`}`}Y4=-!3(o@cVI9<8Hfm
|
||||
z&5bti(|7$Y)|v3Q^Zn-UpA;4kk?aKV*|pOO`<V}&_gU>d_YAOLQ7v(eC`m~yNwrEY
|
||||
zN(E93Mg~S^x&}tNhK3=A7FH(4Rz{ZE1_o9J1{oeVQc*PI=BH$)RpQnlDVlH-sDZ)L
|
||||
L)z4*}Q$iB}B`L3|
|
||||
|
||||
literal 0
|
||||
HcmV?d00001
|
||||
|
||||
diff --git a/packages/console/app/src/routes/brand/index.tsx b/packages/console/app/src/routes/brand/index.tsx
|
||||
index eda3c84..9140462 100644
|
||||
--- a/packages/console/app/src/routes/brand/index.tsx
|
||||
+++ b/packages/console/app/src/routes/brand/index.tsx
|
||||
@@ -7,18 +7,24 @@ import { useI18n } from "~/context/i18n"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
|
||||
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
|
||||
+import previewLogoLightSquare from "../../asset/brand/preview-opencode-logo-light-square.png"
|
||||
+import previewLogoDarkSquare from "../../asset/brand/preview-opencode-logo-dark-square.png"
|
||||
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
|
||||
import previewWordmarkDark from "../../asset/brand/preview-opencode-wordmark-dark.png"
|
||||
import previewWordmarkSimpleLight from "../../asset/brand/preview-opencode-wordmark-simple-light.png"
|
||||
import previewWordmarkSimpleDark from "../../asset/brand/preview-opencode-wordmark-simple-dark.png"
|
||||
import logoLightPng from "../../asset/brand/opencode-logo-light.png"
|
||||
import logoDarkPng from "../../asset/brand/opencode-logo-dark.png"
|
||||
+import logoLightSquarePng from "../../asset/brand/opencode-logo-light-square.png"
|
||||
+import logoDarkSquarePng from "../../asset/brand/opencode-logo-dark-square.png"
|
||||
import wordmarkLightPng from "../../asset/brand/opencode-wordmark-light.png"
|
||||
import wordmarkDarkPng from "../../asset/brand/opencode-wordmark-dark.png"
|
||||
import wordmarkSimpleLightPng from "../../asset/brand/opencode-wordmark-simple-light.png"
|
||||
import wordmarkSimpleDarkPng from "../../asset/brand/opencode-wordmark-simple-dark.png"
|
||||
import logoLightSvg from "../../asset/brand/opencode-logo-light.svg"
|
||||
import logoDarkSvg from "../../asset/brand/opencode-logo-dark.svg"
|
||||
+import logoLightSquareSvg from "../../asset/brand/opencode-logo-light-square.svg"
|
||||
+import logoDarkSquareSvg from "../../asset/brand/opencode-logo-dark-square.svg"
|
||||
import wordmarkLightSvg from "../../asset/brand/opencode-wordmark-light.svg"
|
||||
import wordmarkDarkSvg from "../../asset/brand/opencode-wordmark-dark.svg"
|
||||
import wordmarkSimpleLightSvg from "../../asset/brand/opencode-wordmark-simple-light.svg"
|
||||
@@ -135,6 +141,60 @@ export default function Brand() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+ <div>
|
||||
+ <img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
|
||||
+ <div data-component="actions">
|
||||
+ <button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
|
||||
+ PNG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ <button onClick={() => downloadFile(logoLightSquareSvg, "opencode-logo-light-square.svg")}>
|
||||
+ SVG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
|
||||
+ <div data-component="actions">
|
||||
+ <button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
|
||||
+ PNG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ <button onClick={() => downloadFile(logoDarkSquareSvg, "opencode-logo-dark-square.svg")}>
|
||||
+ SVG
|
||||
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <path
|
||||
+ d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
+ stroke="currentColor"
|
||||
+ stroke-width="1.5"
|
||||
+ stroke-linecap="square"
|
||||
+ />
|
||||
+ </svg>
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
<div>
|
||||
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
--
|
||||
2.39.5
|
||||
|
||||
Reference in New Issue
Block a user