mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 13:41:34 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f567fef67 | |||
| 07d8e2d706 | |||
| 9d9dfb7b61 | |||
| a10949ffba |
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
delay="intent"
|
||||
openDelay={0}
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
@@ -242,42 +241,111 @@ export function ModelSelectorPopoverV2(props: {
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: () => props.onClose?.(),
|
||||
})
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverV2View
|
||||
children={props.children}
|
||||
triggerAs={props.triggerAs}
|
||||
triggerProps={props.triggerProps}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current}
|
||||
select={controller.select}
|
||||
manage={controller.manage}
|
||||
labels={controller.labels}
|
||||
onClose={() => props.onClose?.()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function createModelSelectorController(input: {
|
||||
provider: () => string | undefined
|
||||
model?: ModelState
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const model = input.model ?? useLocal().model
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||
)
|
||||
|
||||
return {
|
||||
models: (search: string) => {
|
||||
const query = search.trim()
|
||||
const filtered = query
|
||||
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
},
|
||||
groups: (models: ModelItem[]) => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
},
|
||||
current: () => {
|
||||
const value = model.current()
|
||||
return value ? modelKey(value) : undefined
|
||||
},
|
||||
select: (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
input.onSelect()
|
||||
},
|
||||
manage: () => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||
})
|
||||
},
|
||||
labels: {
|
||||
search: () => language.t("dialog.model.search.placeholder"),
|
||||
empty: () => language.t("dialog.model.empty"),
|
||||
clear: () => language.t("common.clear"),
|
||||
free: () => language.t("model.tag.free"),
|
||||
latest: () => language.t("model.tag.latest"),
|
||||
manage: () => language.t("dialog.model.manage"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverV2View(props: {
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: () => string | undefined
|
||||
select: (item: ModelItem) => void
|
||||
manage: () => void
|
||||
labels: {
|
||||
search: () => string
|
||||
empty: () => string
|
||||
clear: () => string
|
||||
free: () => string
|
||||
latest: () => string
|
||||
manage: () => string
|
||||
}
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim()
|
||||
const filtered = search
|
||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
const groups = createMemo(() => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models()) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
})
|
||||
const models = createMemo(() => props.models(store.search))
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const current = () => {
|
||||
const value = model.current()
|
||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selected = current()
|
||||
const selected = props.current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
@@ -308,23 +376,15 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
}
|
||||
const select = (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
props.onClose?.()
|
||||
}
|
||||
const selectModel = (item: ModelItem) => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => select(item))
|
||||
afterClose(() => props.select(item))
|
||||
}
|
||||
const manage = () => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||
})
|
||||
})
|
||||
afterClose(props.manage)
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = models().find((item) => modelKey(item) === store.active)
|
||||
@@ -343,10 +403,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
const first = props.models(value)[0]
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
@@ -381,7 +438,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<input
|
||||
ref={(el) => (searchRef = el)}
|
||||
value={store.search}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
placeholder={props.labels.search()}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
@@ -395,7 +452,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
event.preventDefault()
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => props.onClose?.())
|
||||
afterClose(props.onClose)
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
@@ -421,7 +478,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
aria-label={props.labels.clear()}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
</button>
|
||||
@@ -435,7 +492,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
when={models().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
{language.t("dialog.model.empty")}
|
||||
{props.labels.empty()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -445,14 +502,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<MenuV2.RadioGroup value={props.current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
delay="intent"
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
@@ -465,7 +522,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6 w-full"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
@@ -476,10 +533,10 @@ export function ModelSelectorPopoverV2(props: {
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
<TagV2 class="shrink-0">{props.labels.free()}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
<TagV2 class="shrink-0">{props.labels.latest()}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
</TooltipV2>
|
||||
@@ -504,7 +561,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
onSelect={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.labels.manage()}</span>
|
||||
</MenuV2.Item>
|
||||
</div>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -52,7 +52,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createKeybindSettingsController } from "./settings-keybinds"
|
||||
|
||||
function setup(overrides: Record<string, string> = {}) {
|
||||
const changes: [string, string][] = []
|
||||
const suppression: boolean[] = []
|
||||
const notifications: { title: string; description?: string }[] = []
|
||||
let resets = 0
|
||||
let controller: ReturnType<typeof createKeybindSettingsController>
|
||||
|
||||
const dispose = createRoot((dispose) => {
|
||||
controller = createKeybindSettingsController({
|
||||
command: {
|
||||
catalog: [
|
||||
{ id: "session.alpha", title: "Alpha", keybind: "mod+a" },
|
||||
{ id: "session.beta", title: "Beta", keybind: "mod+b" },
|
||||
],
|
||||
options: [],
|
||||
keybinds: (enabled) => suppression.push(enabled),
|
||||
},
|
||||
locale: () => "en",
|
||||
translate: (key, params) => {
|
||||
if (params) return `${key}:${Object.values(params).join("|")}`
|
||||
if (key === "common.key.alt") return "Alt"
|
||||
return String(key)
|
||||
},
|
||||
settings: {
|
||||
current: { keybinds: overrides },
|
||||
keybinds: {
|
||||
get: (id) => overrides[id],
|
||||
set: (id, value) => {
|
||||
overrides[id] = value
|
||||
changes.push([id, value])
|
||||
},
|
||||
resetAll: () => {
|
||||
resets++
|
||||
},
|
||||
},
|
||||
},
|
||||
notify: (toast) => notifications.push(toast),
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
|
||||
return {
|
||||
controller: controller!,
|
||||
changes,
|
||||
suppression,
|
||||
notifications,
|
||||
resets: () => resets,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function modKey(key: string) {
|
||||
const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true })
|
||||
}
|
||||
|
||||
describe("keybind settings controller", () => {
|
||||
test("derives the catalog, effective bindings, and filtered groups", () => {
|
||||
const state = setup({ "session.beta": "alt+k" })
|
||||
|
||||
expect(state.controller.catalog.title("session.alpha")).toBe("Alpha")
|
||||
expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K")
|
||||
expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"])
|
||||
expect(state.controller.settings.hasOverrides()).toBe(true)
|
||||
|
||||
state.dispose()
|
||||
})
|
||||
|
||||
test("captures bindings, rejects conflicts, and restores command handling", () => {
|
||||
const state = setup()
|
||||
|
||||
state.controller.capture.toggle("session.beta")
|
||||
document.dispatchEvent(modKey("a"))
|
||||
expect(state.changes).toEqual([])
|
||||
expect(state.notifications).toHaveLength(1)
|
||||
expect(state.controller.capture.active()).toBe("session.beta")
|
||||
|
||||
document.dispatchEvent(modKey("x"))
|
||||
expect(state.changes).toEqual([["session.beta", "mod+x"]])
|
||||
expect(state.suppression).toEqual([false, true])
|
||||
expect(state.controller.capture.active()).toBeNull()
|
||||
|
||||
state.controller.capture.toggle("session.alpha")
|
||||
state.dispose()
|
||||
expect(state.suppression).toEqual([false, true, false, true])
|
||||
document.dispatchEvent(modKey("z"))
|
||||
expect(state.changes).toEqual([["session.beta", "mod+x"]])
|
||||
})
|
||||
|
||||
test("resets persisted overrides and reports success", () => {
|
||||
const state = setup({ "session.alpha": "none" })
|
||||
|
||||
state.controller.settings.reset()
|
||||
expect(state.resets()).toBe(1)
|
||||
expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title")
|
||||
|
||||
state.dispose()
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,8 @@ type KeybindMeta = {
|
||||
|
||||
type KeybindMap = Record<string, string | undefined>
|
||||
type CommandContext = ReturnType<typeof useCommand>
|
||||
type LanguageContext = ReturnType<typeof useLanguage>
|
||||
type SettingsContext = ReturnType<typeof useSettings>
|
||||
|
||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||
|
||||
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
|
||||
return value as KeybindMap
|
||||
}
|
||||
|
||||
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
||||
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||
|
||||
@@ -262,11 +264,288 @@ function useKeyCapture(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function createKeybindSettingsController(input: {
|
||||
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||
locale: LanguageContext["locale"]
|
||||
translate: LanguageContext["t"]
|
||||
settings: {
|
||||
current: { keybinds: unknown }
|
||||
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
|
||||
}
|
||||
target?: Document
|
||||
notify?: (toast: { title: string; description: string }) => void
|
||||
}) {
|
||||
const [store, setStore] = createStore({ active: null as string | null })
|
||||
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
|
||||
const list = createMemo(() => {
|
||||
input.locale()
|
||||
return listFor(input.command, overrides(), input.translate("command.palette"))
|
||||
})
|
||||
const grouped = createMemo(() => groupedFor(list()))
|
||||
const title = (id: string) => list().get(id)?.title ?? ""
|
||||
const effective = (id: string) => {
|
||||
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
|
||||
|
||||
const custom = input.settings.keybinds.get(id)
|
||||
if (typeof custom === "string") return custom
|
||||
|
||||
const live = input.command.options.find((item) => item.id === id)
|
||||
if (live?.keybind) return live.keybind
|
||||
return input.command.catalog.find((item) => item.id === id)?.keybind
|
||||
}
|
||||
const used = createMemo(() => {
|
||||
const value = new Map<string, { id: string; title: string }[]>()
|
||||
|
||||
for (const id of list().keys()) {
|
||||
for (const signature of signatures(effective(id))) {
|
||||
const items = value.get(signature)
|
||||
if (items) {
|
||||
items.push({ id, title: title(id) })
|
||||
continue
|
||||
}
|
||||
value.set(signature, [{ id, title: title(id) }])
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
})
|
||||
const stop = () => {
|
||||
if (!store.active) return
|
||||
setStore("active", null)
|
||||
input.command.keybinds(true)
|
||||
}
|
||||
const toggle = (id: string) => {
|
||||
if (store.active === id) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (store.active) stop()
|
||||
setStore("active", id)
|
||||
input.command.keybinds(false)
|
||||
}
|
||||
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
|
||||
|
||||
onMount(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = store.active
|
||||
if (!id) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const clear =
|
||||
(event.key === "Backspace" || event.key === "Delete") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
if (clear) {
|
||||
input.settings.keybinds.set(id, "none")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const next = recordKeybind(event)
|
||||
if (!next) return
|
||||
|
||||
const conflicts = new Map<string, string>()
|
||||
for (const signature of signatures(next)) {
|
||||
for (const item of used().get(signature) ?? []) {
|
||||
if (item.id === id) continue
|
||||
conflicts.set(item.id, item.title)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
notify({
|
||||
title: input.translate("settings.shortcuts.conflict.title"),
|
||||
description: input.translate("settings.shortcuts.conflict.description", {
|
||||
keybind: formatKeybind(next, input.translate),
|
||||
titles: [...conflicts.values()].join(", "),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.settings.keybinds.set(id, next)
|
||||
stop()
|
||||
}
|
||||
|
||||
makeEventListener(input.target ?? document, "keydown", handle, { capture: true })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.active) input.command.keybinds(true)
|
||||
})
|
||||
|
||||
return {
|
||||
catalog: {
|
||||
groups: GROUPS,
|
||||
filtered: (query: string) =>
|
||||
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", input.translate)),
|
||||
title,
|
||||
keybind: (id: string) => formatKeybind(effective(id) ?? "", input.translate),
|
||||
},
|
||||
capture: {
|
||||
active: () => store.active,
|
||||
toggle,
|
||||
},
|
||||
settings: {
|
||||
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
|
||||
reset: () => {
|
||||
stop()
|
||||
input.settings.keybinds.resetAll()
|
||||
notify({
|
||||
title: input.translate("settings.shortcuts.reset.toast.title"),
|
||||
description: input.translate("settings.shortcuts.reset.toast.description"),
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function SettingsKeybindsV2(props: { command: CommandContext; language: LanguageContext; settings: SettingsContext }) {
|
||||
const controller = createKeybindSettingsController({
|
||||
command: props.command,
|
||||
locale: props.language.locale,
|
||||
translate: props.language.t,
|
||||
settings: props.settings,
|
||||
})
|
||||
|
||||
return (
|
||||
<SettingsKeybindsV2View
|
||||
groups={controller.catalog.groups}
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
keybind={controller.catalog.keybind}
|
||||
active={controller.capture.active}
|
||||
onCapture={controller.capture.toggle}
|
||||
hasOverrides={controller.settings.hasOverrides}
|
||||
onReset={controller.settings.reset}
|
||||
groupLabel={(group) => props.language.t(groupKey[group])}
|
||||
titleLabel={() => props.language.t("settings.shortcuts.title")}
|
||||
resetLabel={() => props.language.t("settings.shortcuts.reset.button")}
|
||||
searchLabel={() => props.language.t("settings.shortcuts.search.placeholder")}
|
||||
emptyLabel={() => props.language.t("settings.shortcuts.search.empty")}
|
||||
unassignedLabel={() => props.language.t("settings.shortcuts.unassigned")}
|
||||
pressKeysLabel={() => props.language.t("settings.shortcuts.pressKeys")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsKeybindsV2View(props: {
|
||||
groups: KeybindGroup[]
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
keybind: (id: string) => string
|
||||
active: () => string | null
|
||||
onCapture: (id: string) => void
|
||||
hasOverrides: () => boolean
|
||||
onReset: () => void
|
||||
groupLabel: (group: KeybindGroup) => string
|
||||
titleLabel: () => string
|
||||
resetLabel: () => string
|
||||
searchLabel: () => string
|
||||
emptyLabel: () => string
|
||||
unassignedLabel: () => string
|
||||
pressKeysLabel: () => string
|
||||
}) {
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const filtered = createMemo(() => props.filtered(store.filter))
|
||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{props.titleLabel()}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
{props.resetLabel()}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={props.searchLabel()}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={props.searchLabel()}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-shortcuts flex flex-col gap-8">
|
||||
<For each={props.groups}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{props.groupLabel(group)}</h3>
|
||||
<SettingsListV2>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span>{props.title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": true,
|
||||
"settings-v2-keybind-button--active": props.active() === id,
|
||||
}}
|
||||
onClick={() => props.onCapture(id)}
|
||||
>
|
||||
<Show when={props.active() === id} fallback={props.keybind(id) || props.unassignedLabel()}>
|
||||
{props.pressKeysLabel()}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="settings-v2-shortcuts-status">
|
||||
<span>{props.emptyLabel()}</span>
|
||||
<span class="settings-v2-shortcuts-status-filter">"{store.filter}"</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
if (props.v2) return <SettingsKeybindsV2 command={command} language={language} settings={settings} />
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
active: null as string | null,
|
||||
filter: "",
|
||||
@@ -476,78 +755,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.v2}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">{groups}</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -344,7 +344,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.previous,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
@@ -352,7 +361,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.next,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
function history(): TabHistory {
|
||||
return { stack: [], index: -1 }
|
||||
}
|
||||
|
||||
describe("tab history", () => {
|
||||
test("moves backward and forward through selected tabs", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const available = new Set(selected.stack)
|
||||
|
||||
const previous = previousTab(selected, available)
|
||||
expect(previous?.key).toBe("b")
|
||||
|
||||
const first = previousTab(previous!.state, available)
|
||||
expect(first?.key).toBe("a")
|
||||
|
||||
const next = nextTab(first!.state, available)
|
||||
expect(next?.key).toBe("b")
|
||||
})
|
||||
|
||||
test("replaces forward history after a new selection", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const previous = previousTab(selected, new Set(selected.stack))
|
||||
const next = rememberTab(previous!.state, "d")
|
||||
|
||||
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips tabs that are no longer open", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||
})
|
||||
|
||||
test("skips a repeated current tab after closing the previous selection", () => {
|
||||
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
const MAX_TAB_HISTORY = 100
|
||||
|
||||
export type TabHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||
if (state.stack[state.index] === key) return state
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||
return { stack, index: stack.length - 1 }
|
||||
}
|
||||
|
||||
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, -1, available)
|
||||
}
|
||||
|
||||
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, 1, available)
|
||||
}
|
||||
|
||||
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||
const current = state.stack[state.index]
|
||||
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||
const key = state.stack[index]
|
||||
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -80,7 +79,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let history: TabHistory = { stack: [], index: -1 }
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
@@ -152,21 +150,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
history = rememberTab(history, tabKey(tab))
|
||||
setRecentKey(tabKey(tab))
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const moveHistory = (direction: "previous" | "next") => {
|
||||
const available = new Set(store.map(tabKey))
|
||||
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||
if (!result) return
|
||||
const tab = store.find((item) => tabKey(item) === result.key)
|
||||
if (!tab) return
|
||||
history = result.state
|
||||
navigateTab(tab)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
@@ -372,11 +359,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
const key = tabKey(tab)
|
||||
history = rememberTab(history, key)
|
||||
if (recentKey() !== key) setRecentKey(key)
|
||||
},
|
||||
previous: () => moveHistory("previous"),
|
||||
next: () => moveHistory("next"),
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
|
||||
@@ -2,17 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("navigates between tabs", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||
|
||||
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
|
||||
@@ -271,6 +271,7 @@ function ProviderTip(props: { ready: () => boolean; connected: () => boolean; op
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
batch,
|
||||
ErrorBoundary,
|
||||
onCleanup,
|
||||
Suspense,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
@@ -2298,53 +2297,49 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={newSessionDesign()}>
|
||||
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={reviewV2State.sidebarOpened()}
|
||||
disabled={disabled}
|
||||
onToggle={reviewV2State.toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
fileBrowserState={reviewV2State}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
stacked={desktopV2PanelLayout().stacked}
|
||||
/>
|
||||
</Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={reviewV2State.sidebarOpened()}
|
||||
disabled={disabled}
|
||||
onToggle={reviewV2State.toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
fileBrowserState={reviewV2State}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
stacked={desktopV2PanelLayout().stacked}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={desktopV2PanelLayout().stacked}>
|
||||
|
||||
@@ -377,8 +377,6 @@
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
/* Prevent Safari from repainting child SVGs mid-transition. */
|
||||
backface-visibility: hidden;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
position: static;
|
||||
|
||||
@@ -32,6 +32,7 @@ export function CommentCardV2(props: {
|
||||
return (
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={props.title ?? props.comment}
|
||||
disabled={!props.tooltip || !truncated()}
|
||||
class={props.wide ? "w-full" : undefined}
|
||||
|
||||
@@ -385,7 +385,12 @@ export function PromptInputV2Attachments(props: {
|
||||
<For each={props.comments ?? []}>
|
||||
{(comment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<TooltipV2
|
||||
value={comment.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
|
||||
@@ -214,6 +214,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
</Show>
|
||||
<div class="flex items-center">
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!prev()}
|
||||
value={
|
||||
<>
|
||||
@@ -233,6 +234,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!next()}
|
||||
value={
|
||||
<>
|
||||
@@ -266,12 +268,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||
>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<Icon name="expand" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<Icon name="collapse" />
|
||||
</SegmentedControlItemV2>
|
||||
@@ -287,12 +289,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||
>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<Icon name="unified" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<Icon name="split" />
|
||||
</SegmentedControlItemV2>
|
||||
|
||||
@@ -104,6 +104,7 @@ const appGlobalBindingCommands = [
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -962,11 +963,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
commands: appCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => dialog.stack.length === 0,
|
||||
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
|
||||
@@ -5,13 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "../src/keymap"
|
||||
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
||||
|
||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||
const keybinds = TuiKeybind.parse(input)
|
||||
@@ -79,14 +73,12 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: COMMAND_PALETTE_COMMAND, run() {} },
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
@@ -103,14 +95,7 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
"model.list",
|
||||
],
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
@@ -140,24 +125,9 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 1,
|
||||
},
|
||||
question: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
autocomplete: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
border: none;
|
||||
background:
|
||||
linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%),
|
||||
var(--v2-background-bg-layer-04);
|
||||
var(--v2-background-bg-layer-03);
|
||||
box-shadow: var(--v2-elevation-switch-off);
|
||||
transition:
|
||||
background 90ms ease-out,
|
||||
@@ -90,7 +90,7 @@
|
||||
background:
|
||||
linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
|
||||
linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%),
|
||||
var(--v2-background-bg-layer-04);
|
||||
var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
&:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
const OPEN_DELAY = 1_000
|
||||
|
||||
// Kobalte warms every tooltip globally. Keep intent previews isolated so opening
|
||||
// a model picker never inherits warm state from an unrelated tooltip.
|
||||
let warm = false
|
||||
let reset: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
export function openTooltipIntent(open: () => void) {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
if (warm) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
warm = true
|
||||
open()
|
||||
}, OPEN_DELAY)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
export function closeTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
// Adjacent triggers enter before the next task; leaving the group does not.
|
||||
reset = setTimeout(() => {
|
||||
warm = false
|
||||
reset = undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function resetTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
warm = false
|
||||
}
|
||||
@@ -2,22 +2,19 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
|
||||
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { closeTooltipIntent, openTooltipIntent, resetTooltipIntent } from "./tooltip-intent"
|
||||
import "./tooltip-v2.css"
|
||||
|
||||
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
|
||||
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
|
||||
value: JSX.Element
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
inactive?: boolean
|
||||
delay?: "standard" | "intent"
|
||||
forceOpen?: boolean
|
||||
}
|
||||
|
||||
export function TooltipV2(props: TooltipV2Props) {
|
||||
let ref: HTMLDivElement | undefined
|
||||
let cancelIntent: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
block: false,
|
||||
@@ -29,37 +26,19 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
"contentClass",
|
||||
"contentStyle",
|
||||
"inactive",
|
||||
"delay",
|
||||
"forceOpen",
|
||||
"ignoreSafeArea",
|
||||
"value",
|
||||
])
|
||||
|
||||
const close = () => setState("open", false)
|
||||
|
||||
const inside = () => {
|
||||
const active = document.activeElement
|
||||
if (!ref || !active) return false
|
||||
return ref.contains(active)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
cancelIntent?.()
|
||||
cancelIntent = undefined
|
||||
if (local.delay === "intent") closeTooltipIntent()
|
||||
setState("open", false)
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
if (local.delay !== "intent" || inside()) {
|
||||
setState("open", true)
|
||||
return
|
||||
}
|
||||
if (cancelIntent) return
|
||||
cancelIntent = openTooltipIntent(() => {
|
||||
cancelIntent = undefined
|
||||
setState("open", true)
|
||||
})
|
||||
}
|
||||
|
||||
const drop = (expand = state.expand) => {
|
||||
if (expand) return
|
||||
if (ref?.matches(":hover")) return
|
||||
@@ -101,11 +80,6 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
onCleanup(() => obs.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelIntent?.()
|
||||
if (local.delay === "intent") resetTooltipIntent()
|
||||
})
|
||||
|
||||
let justClickedTrigger = false
|
||||
|
||||
return (
|
||||
@@ -114,8 +88,8 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
<Match when={true}>
|
||||
<KobalteTooltip
|
||||
gutter={4}
|
||||
openDelay={local.delay === "intent" ? 0 : 400}
|
||||
skipDelayDuration={local.delay === "intent" ? 0 : 300}
|
||||
openDelay={400}
|
||||
skipDelayDuration={300}
|
||||
{...others}
|
||||
closeDelay={0}
|
||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||
@@ -127,11 +101,7 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
justClickedTrigger = false
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
close()
|
||||
setState("open", open)
|
||||
}}
|
||||
>
|
||||
<KobalteTooltip.Trigger
|
||||
|
||||
@@ -107,7 +107,6 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -150,7 +149,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -105,7 +105,6 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -148,7 +147,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -112,7 +112,6 @@ You can also access our models through the following API endpoints.
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -157,7 +156,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -103,7 +103,6 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -146,7 +145,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
@@ -107,7 +107,6 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -151,7 +150,6 @@ https://opencode.ai/zen/v1/models
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
|
||||
Reference in New Issue
Block a user