Compare commits

...

36 Commits

Author SHA1 Message Date
Aiden Cline
8abfd71684 refactor(apply_patch): avoid redundant file reads during update verification
Make patch derivation async and allow callers to provide existing file content so apply_patch update checks reuse already-loaded text instead of reading the same file twice.
2026-02-17 19:51:23 -06:00
Aiden Cline
0ca75544ab fix: dont autoload kilo (#14052) 2026-02-17 18:42:18 -06:00
opencode-agent[bot]
572a037e5d chore: generate 2026-02-17 23:53:22 +00:00
RAMA
ad92181fa7 feat: add Kilo as a native provider (#13765) 2026-02-17 17:52:21 -06:00
legao
c56f4aa5d8 refactor: simplify redundant ternary in updateMessage (#13954) 2026-02-17 17:40:14 -06:00
opencode-agent[bot]
a344a766fd chore: generate 2026-02-17 23:36:08 +00:00
Aiden Cline
bca793d064 ci: ensure triage adds acp label (#14039) 2026-02-17 17:34:47 -06:00
Dax Raad
ad3c192837 tui: exit cleanly without hanging after session ends
- Force process exit after TUI thread completes to prevent lingering processes
- Add 5-second timeout to worker shutdown to prevent indefinite hangs during cleanup
2026-02-17 17:56:39 -05:00
Anton Volkov
5512231ca8 fix(tui): style scrollbox for permission and sidebar (#12752) 2026-02-17 16:24:01 -06:00
Anton Volkov
bad394cd49 chore: remove leftover patch (#13749) 2026-02-17 16:22:38 -06:00
Aiden Cline
3b97580621 tweak: ensure read tool uses fs/promises for all paths (#14027) 2026-02-17 16:05:22 -06:00
jackarch-2
cb88fe26aa chore: add missing newline (#13992) 2026-02-17 16:04:58 -06:00
Adam
e345b89ce5 fix(app): better tool call batching 2026-02-17 15:57:50 -06:00
Adam
26c7b240ba chore: cleanup 2026-02-17 15:54:59 -06:00
Adam
d327a2b1cf chore(app): use radio group in prompt input (#14025) 2026-02-17 15:53:38 -06:00
Aiden Cline
c1b03b728a fix: make read tool more mem efficient (#14009) 2026-02-17 15:36:45 -06:00
opencode-agent[bot]
2a2437bf22 chore: generate 2026-02-17 21:23:23 +00:00
Nathan Anderson
4ccb82e81a feat: surface plugin auth providers in the login picker (#13921)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-17 15:21:49 -06:00
David Hill
92912219df tui: simplify prompt mode toggle icon colors via CSS and tighten message timeline padding 2026-02-17 20:10:16 +00:00
Adam
bab3124e8b fix(app): prompt input quirks 2026-02-17 13:10:43 -06:00
Frank
7a66ec6bc9 zen: sonnet 4.6 2026-02-17 14:10:21 -05:00
Adam
3a505b2691 fix(app): virtualizer getting wrong scroll root 2026-02-17 12:57:40 -06:00
Adam
20f43372f6 fix(app): terminal disconnect and resync (#14004) 2026-02-17 12:54:28 -06:00
Eduardo Gomes
fb79dd7bf8 fix: Invalidate oauth credentials when oauth provider says so (#14007)
Co-authored-by: Eduardo Gomes <egomes@cloudflare.com>
2026-02-17 12:46:26 -06:00
Brendan Allan
4025b655a4 desktop: replicate tauri-plugin-shell logic (#13986) 2026-02-18 02:40:52 +08:00
David Hill
7379903568 tui: improve modified file visibility and button spacing
- Replace warning yellow with distinct orange color for modified files in git diff indicators
- Increase button padding for better visual balance in session header and status popover
2026-02-17 18:39:21 +00:00
David Hill
a685e7a805 tui: show monochrome file icons by default in tree view, revealing colors on hover to reduce visual clutter and help users focus on code content 2026-02-17 18:23:04 +00:00
David Hill
ce7484b4f5 tui: fix share button text styling to use consistent 12px regular font weight 2026-02-17 17:55:55 +00:00
David Hill
0bc1dcbe1b tweak(ui): update icon transparency 2026-02-17 17:52:29 +00:00
David Hill
a69b339baf fix(ui): use icon-strong-base for active titlebar icon buttons 2026-02-17 17:51:49 +00:00
David Hill
26f835cdd2 tweak(ui): icon-interactive-base color change dark mode 2026-02-17 17:43:37 +00:00
David Hill
bd3d1413fd tui: add warning icon to permission requests for better visibility
Adds a visual warning indicator to permission request dialogs to make

them more noticeable and help users understand when the agent needs

approval to use tools. Also improves the layout with consistent

spacing and icon alignment.
2026-02-17 17:43:37 +00:00
David Hill
2c17a980ff refactor(ui): extract dock prompt shell 2026-02-17 17:43:37 +00:00
David Hill
b784c923a8 tweak(ui): bump button heights and align permission prompt layout 2026-02-17 17:43:37 +00:00
Aiden Cline
ea96f898c0 ci: rm remap for jlongster since he is in org now (#14000) 2026-02-17 11:08:35 -06:00
Caleb Norton
47435f6e17 fix: don't fetch models.dev on completion (#13997) 2026-02-17 10:41:03 -06:00
77 changed files with 1773 additions and 898 deletions

View File

@@ -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)

View File

@@ -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'")

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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={

View File

@@ -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 (

View File

@@ -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": "ما هو المكدس التقني لهذا المشروع؟",

View File

@@ -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?",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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?",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 ?",

View File

@@ -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": "このプロジェクトの技術スタックは何ですか?",

View File

@@ -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": "이 프로젝트의 기술 스택이 무엇인가요?",

View File

@@ -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",

View File

@@ -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?",

View File

@@ -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 в коде",

View File

@@ -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 ในโค้ดเบส",

View File

@@ -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": "这个项目的技术栈是什么?",

View File

@@ -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",

View File

@@ -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,
}}
>

View File

@@ -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}>

View File

@@ -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)
})
})

View File

@@ -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 }

View File

@@ -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;
}
}
}
}

View File

@@ -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",

View File

@@ -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()

View File

@@ -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

View File

@@ -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}>

View File

@@ -184,5 +184,6 @@ export const TuiThreadCommand = cmd({
} finally {
unguard?.()
}
process.exit(0)
},
})

View File

@@ -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)
},
}

View File

@@ -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 }

View File

@@ -1,7 +1,6 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util/log"
export namespace Patch {
@@ -308,16 +307,18 @@ export namespace Patch {
content: string
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}
export async function deriveNewContentsFromChunks(
filePath: string,
chunks: UpdateFileChunk[],
originalContent?: string,
): Promise<ApplyPatchFileUpdate> {
const content =
originalContent ??
(await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}))
let originalLines = originalContent.split("\n")
let originalLines = content.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -335,7 +336,7 @@ export namespace Patch {
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
const unifiedDiff = generateUnifiedDiff(content, newContent)
return {
unified_diff: unifiedDiff,
@@ -545,7 +546,7 @@ export namespace Patch {
break
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
const fileUpdate = await deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
@@ -641,7 +642,7 @@ export namespace Patch {
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
const fileUpdate = await deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,

View File

@@ -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 () => {

View File

@@ -578,6 +578,17 @@ export namespace Provider {
},
}
},
kilo: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
}
export const Model = z

View File

@@ -579,7 +579,7 @@ export namespace Session {
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
const time_created = msg.role === "user" ? msg.time.created : msg.time.created
const time_created = msg.time.created
const { id, sessionID, ...data } = msg
Database.use((db) => {
db.insert(MessageTable)

View File

@@ -101,7 +101,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
const fileUpdate = await Patch.deriveNewContentsFromChunks(filePath, hunk.chunks, oldContent)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)

View File

@@ -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
}

View 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([])
})
})

View File

@@ -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",
)
},
})
})
})

View 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);
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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"/>`,

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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": "إجراء تعديلات",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "編集を実行中",

View File

@@ -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": "편집 수행 중",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Внесение изменений",

View File

@@ -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": "กำลังแก้ไข",

View File

@@ -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": "正在修改",

View File

@@ -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": "正在修改",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)"

View File

@@ -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)",

View File

@@ -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 | - |

View File

@@ -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