Compare commits

...

20 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
53 changed files with 819 additions and 503 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

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

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

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

@@ -65,7 +65,15 @@ function EditBody(props: { request: PermissionRequest }) {
return (
<box flexDirection="column" gap={1}>
<Show when={diff()}>
<scrollbox height="100%">
<scrollbox
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<diff
diff={diff()}
view={view()}

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

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

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

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

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

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