Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/release-cli-linux-amd64.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: release-cli-linux-amd64

"on":
workflow_dispatch:
push:
tags:
- "v*"
- "!vscode-v*"
- "!github-v*"

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: ./.github/actions/setup-bun

- name: Fetch tags
run: git fetch --force --tags

- name: Ensure tag is on master
run: |
git fetch --force origin master
if ! git branch -r --contains "$GITHUB_SHA" | grep -q "origin/master"; then
echo "Tag commit is not contained in origin/master: $GITHUB_SHA"
exit 1
fi

- name: Create release if missing
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="$GITHUB_REF_NAME"
if gh release view "$tag"; then
exit 0
fi
gh release create "$tag" --title "$tag" --generate-notes

- name: Build CLI (linux/amd64)
env:
GH_TOKEN: ${{ github.token }}
OPENCODE_RELEASE: "1"
run: |
version="$GITHUB_REF_NAME"
version="${version#v}"
export OPENCODE_VERSION="$version"
./packages/opencode/script/build.ts --single
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ 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 { IconButton } from "@opencode-ai/ui/icon-button"
import { Markdown } from "@opencode-ai/ui/markdown"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
Expand Down Expand Up @@ -234,6 +236,30 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("editing", false)
}

const copy = () => {
const text = question()?.question ?? ""
if (!text) return

const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return
}

const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return
clipboard.writeText(text).catch(() => {})
}

return (
<DockPrompt
kind="question"
Expand All @@ -242,6 +268,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
<>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<IconButton
type="button"
icon="copy"
size="small"
variant="ghost"
aria-label={language.t("ui.message.copy")}
onMouseDown={(e) => e.preventDefault()}
onClick={copy}
/>
<For each={questions()}>
{(_, i) => (
<button
Expand Down Expand Up @@ -279,7 +314,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Markdown data-slot="question-text" text={question()?.question ?? ""} />
<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>
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,16 @@ if (Script.release) {
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
}
}
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`

const zips = await Array.fromAsync(new Bun.Glob("dist/*.zip").scan())
const tars = await Array.fromAsync(new Bun.Glob("dist/*.tar.gz").scan())
const files = [...zips, ...tars]
if (files.length === 0) {
console.warn("No release archives found in dist; skipping gh release upload")
} else {
const repo = process.env.GH_REPO ? ["--repo", process.env.GH_REPO] : []
await $`gh release upload v${Script.version} ${files} --clobber ${repo}`
}
}

export { binaries }
10 changes: 8 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2122,7 +2122,7 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
}

function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)

function format(answer?: string[]) {
Expand All @@ -2138,7 +2138,13 @@ function Question(props: ToolProps<typeof QuestionTool>) {
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={syntax()}
content={q.question}
fg={theme.textMuted}
/>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
Expand Down
13 changes: 8 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useDialog } from "../../ui/dialog"

export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()

Expand Down Expand Up @@ -314,10 +314,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{question()?.question}
{multi() ? " (select all that apply)" : ""}
</text>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={syntax()}
content={(question()?.question ?? "") + (multi() ? " (select all that apply)" : "")}
fg={theme.text}
/>
</box>
<box>
<For each={options()}>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/question.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ Usage notes:
- When `custom` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
- Format question text using Markdown when helpful (e.g., **bold** for emphasis, `code` for technical terms, lists for multiple points)
57 changes: 55 additions & 2 deletions packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,59 @@ export function BasicTool(props: BasicToolProps) {
)
}

export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export function GenericTool(props: {
tool: string
status?: string
hideDetails?: boolean
input?: unknown
output?: string
}) {
const format = (value: unknown) => {
if (value === undefined) return ""
if (value === null) return "null"
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value === "object") {
if (Array.isArray(value) && value.length === 0) return ""
if (!Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) return ""
}

try {
return JSON.stringify(
value,
(_key, next) => (typeof next === "bigint" ? next.toString() : next),
2,
)
} catch {
return String(value)
}
}

const text = () => {
const ins = format(props.input)
const out = typeof props.output === "string" ? props.output : ""
const blocks: string[] = []
if (ins) blocks.push(`INPUT\n${ins}`)
if (out) blocks.push(`OUTPUT\n${out}`)
return blocks.join("\n\n")
}

const hasDetails = () => !!text()

return (
<BasicTool
icon="mcp"
status={props.status}
trigger={{ title: props.tool }}
hideDetails={props.hideDetails || !hasDetails()}
>
<Show when={hasDetails()}>
<div data-component="tool-output" data-scrollable>
<pre>
<code>{text()}</code>
</pre>
</div>
</Show>
</BasicTool>
)
}
7 changes: 7 additions & 0 deletions packages/ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,10 @@
[data-component="write-content"],
[data-component="todos"],
[data-component="diagnostics"],
:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) [data-slot="question-text"],
:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) [data-slot="question-hint"],
[data-component="question-answers"] [data-slot="question-text"],
[data-component="question-answers"] [data-slot="answer-text"],
.error-card {
-webkit-user-select: text;
user-select: text;
Expand Down Expand Up @@ -963,6 +967,9 @@
line-height: var(--line-height-large);
color: var(--text-strong);
padding: 0 10px;
min-height: 0;
flex: 0 1 auto;
overflow-y: auto;
}

[data-slot="question-hint"] {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1859,7 +1859,7 @@ ToolRegistry.register({
const answer = () => answers()[i()] ?? []
return (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<Markdown data-slot="question-text" text={q.question ?? ""} />
<div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
</div>
)
Expand Down
Loading