chore: merge dev into v2 (#37996)

Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-07-20 15:20:59 -05:00 committed by GitHub
commit 4c9a4309ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
209 changed files with 6882 additions and 2469 deletions

View file

@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & {
memory?: {
@ -107,8 +108,45 @@ function Cell(props: {
)
}
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()}
</Tooltip>
)
}
export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage()
const platform = usePlatform()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
focus: false,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
const toggleFocus = async () => {
if (!platform.setForceFocus) return
const enabled = !state.focus
await platform.setForceFocus(enabled)
setState("focus", enabled)
}
onCleanup(() => {
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
})
let prev = ""
let start = 0
@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
wide
wide={!platform.setForceFocus}
/>
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
)}
</div>
</aside>
)

View file

@ -0,0 +1,73 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/context/server-sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
function ConnectProviderDialogStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogConnectProvider />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open connect provider dialog
</Button>
)
}
function ProviderConnectionDialogStory(props) {
onCleanup(mockProviderAuth(props.provider, props.methods))
const dialog = useDialog()
const controller = useProviderConnectController()
controller.select(props.provider)
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open {props.provider} connection dialog
</Button>
)
}
function renderConnection(provider, methods) {
return () => (
<QueryClientProvider client={new QueryClient()}>
<ProviderConnectionDialogStory provider={provider} methods={methods} />
</QueryClientProvider>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-connect-provider",
}
export const V2 = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
</QueryClientProvider>
),
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "api", label: "API key" },
]),
}

View file

@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import {
type Accessor,
@ -16,6 +19,8 @@ import {
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
@ -27,6 +32,7 @@ import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const reset = controller.back
const back = { current: reset }
let focusHost: HTMLDivElement | undefined
const holdFocus = () => focusHost?.focus({ preventScroll: true })
const select = (provider?: string) => {
back.current = reset
controller.select(provider)
}
return (
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
function Content() {
return (
<Switch>
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm />
<CustomProviderForm autofocus={!newLayout()} />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => (
@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
)}
</Match>
<Match when={true}>
<ProviderPicker directory={props.directory} onSelect={select} />
<ProviderPicker
directory={props.directory}
onSelect={select}
onPrepare={newLayout() ? holdFocus : undefined}
/>
</Match>
</Switch>
</Dialog>
)
}
return (
<Show
when={newLayout()}
fallback={
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Content />
</Dialog>
}
>
<DialogV2
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
</DialogBody>
</DialogV2>
</Show>
)
}
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
)
}
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(props.directory)
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
const popular = createMemo(() =>
all()
.filter((provider) => featured.includes(provider.id))
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
)
const other = createMemo(() =>
all()
.filter((provider) => !featured.includes(provider.id))
.sort((a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
return a.name.localeCompare(b.name)
}),
)
const rows = createMemo(() => [...popular(), ...other()])
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
onMount(() => search?.focus({ preventScroll: true }))
const connect = (provider: string) => {
props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
}
const move = (event: KeyboardEvent, direction: number) => {
const items = rows()
if (items.length === 0) return
const index = items.findIndex((provider) => provider.id === store.active)
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
setStore("active", items[next].id)
picker
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
?.focus({ preventScroll: true })
event.preventDefault()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") return move(event, 1)
if (event.key === "ArrowUp") return move(event, -1)
if (event.key !== "Enter" || !store.active) return
connect(store.active)
event.preventDefault()
}
return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px">
<TextInputV2
ref={search}
type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
placeholder={language.t("dialog.provider.search.placeholder")}
value={store.filter}
onInput={(event) => {
setStore({ filter: event.currentTarget.value, active: undefined })
}}
/>
</div>
<div class="relative min-h-0 flex-1">
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<For
each={[
{ title: language.t("dialog.provider.group.popular"), items: popular },
{ title: language.t("dialog.provider.group.other"), items: other },
]}
>
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("dialog.provider.tag.recommended")}
</span>
</Show>
<Show when={provider.id === CUSTOM_ID}>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("settings.providers.tag.custom")}
</span>
</Show>
<Show when={store.connecting === provider.id}>
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
</section>
</Show>
)}
</For>
<Show when={rows().length === 0}>
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
{language.t("dialog.provider.empty")}
</div>
</Show>
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
/>
</div>
</div>
)
}
function ProviderConnection(props: {
provider: string
directory?: Accessor<string | undefined>
@ -173,6 +396,8 @@ function ProviderConnection(props: {
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory)
const alive = { value: true }
@ -207,11 +432,19 @@ function ProviderConnection(props: {
)
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined,
})
@ -279,6 +512,16 @@ function ProviderConnection(props: {
return value.label ?? ""
}
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
}
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
@ -519,6 +762,37 @@ function ProviderConnection(props: {
props.setBack(goBack)
function MethodSelection() {
if (newLayout())
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void selectMethod(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
)
}}
</For>
</div>
</div>
)
return (
<>
<div class="text-14-regular text-text-base">
@ -552,11 +826,18 @@ function ProviderConnection(props: {
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (!newLayout()) return
apiKey?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@ -581,6 +862,58 @@ function ProviderConnection(props: {
await complete()
}
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInputV2
ref={apiKey}
class="!w-full"
name="apiKey"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return (
<div class="flex flex-col gap-6">
<Switch>
@ -605,7 +938,8 @@ function ProviderConnection(props: {
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
autofocus={!newLayout()}
ref={apiKey}
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
@ -624,11 +958,18 @@ function ProviderConnection(props: {
}
function OAuthCodeView() {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (!newLayout()) return
codeInput?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@ -657,6 +998,46 @@ function ProviderConnection(props: {
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
<TextInputV2
ref={codeInput}
class="!w-full"
name="code"
placeholder={language.t("provider.connect.oauth.code.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
@ -666,7 +1047,8 @@ function ProviderConnection(props: {
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
autofocus={!newLayout()}
ref={codeInput}
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
@ -738,10 +1120,19 @@ function ProviderConnection(props: {
}
return (
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
<ProviderIcon
id={props.provider}
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
/>
<div
class={
newLayout()
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
: "text-16-medium text-text-strong"
}
>
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
@ -750,8 +1141,12 @@ function ProviderConnection(props: {
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
<div
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">

View file

@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
)
}
export function CustomProviderForm() {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
@ -192,7 +192,7 @@ export function CustomProviderForm() {
<div class="flex flex-col gap-4">
<TextField
autofocus
autofocus={props.autofocus ?? true}
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}

View file

@ -0,0 +1,55 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal, onMount } from "solid-js"
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
const names = [
"MiMo V2.5 Free",
"Nemotron 3 Ultra Free",
"Deepseek V4 Flash Free",
"North Mini Code Free",
"Hy3 Free",
"Big Pickle",
]
function SelectModelWithoutProviders() {
const dialog = useDialog()
const models = names.map((name, index) => ({
id: name.toLowerCase().replaceAll(" ", "-"),
name,
provider: { id: "opencode", name: "OpenCode" },
cost: { input: 0, output: 0 },
limit: { context: 128_000 },
capabilities: {
reasoning: index !== 5,
input: { text: true, image: false, audio: false, video: false, pdf: false },
},
}))
const [current, setCurrent] = createSignal(models[2])
const model = {
list: () => models,
current,
set(value) {
setCurrent(models.find((item) => item.id === value?.modelID))
},
}
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open select model dialog
</Button>
)
}
export default {
title: "App/Dialogs/Select Model",
id: "app-dialog-select-model",
}
export const WithoutProviders = {
render: () => <SelectModelWithoutProviders />,
}

View file

@ -1,23 +1,26 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
})
return (
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
<DialogV2
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<DialogBody class="min-h-0 flex-1 gap-0">
<ScrollView class="min-h-0 flex-1 w-full">
<div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
<For each={model.list()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
</button>
)}
</For>
<For each={freeModels()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
<button
type="button"
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="dot-grid" size="small" />
</span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={[...providers.popular()]
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
{(provider) => (
<button
type="button"
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
</Show>
</span>
</button>
)}
</For>
<button
type="button"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
{language.t("dialog.model.unpaid.viewMoreProviders")}
</button>
</div>
</div>
</div>
</ScrollView>
</div>
</DialogBody>
</DialogV2>
)

View file

@ -16,6 +16,7 @@ export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
@ -70,12 +71,40 @@ export function TabsInfoPopup() {
</button>
</div>
</Show>
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<DrawerContent
style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"

View file

@ -0,0 +1,591 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
useCommands(props)
useEditHandler(props)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
paid={props.controller.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controller.model.selection}
providerID={props.controller.model.selection.current()?.provider?.id}
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.controller.restoreFocus}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
}
/>
}
/>
</div>
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
const files = useFile()
const layout = useLayout()
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
let editor: HTMLDivElement | undefined
const interaction = createPromptInputV2State()
const mode = () => interaction[0].mode
const history = props.history ?? createPersistedPromptInputHistory()
const tabs = () => props.controls.session.tabs
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const recent = createMemo(() => {
const all = tabs().all()
const active = activeFileTab()
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
return order.reduce<string[]>((result, tab) => {
const path = files.pathFromTab(tab)
if (!path || result.includes(path)) return result
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: mode(),
commentCount: commentCount(),
example: mode() === "shell" ? "git status" : "",
suggest: false,
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission = createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
promptLength,
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistoryNavigation: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
setPopover: (popover) => {
if (!popover) controller.dispatch({ type: "popover.close" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map((reference) => ({
id: `reference:${reference.name}`,
kind: "reference" as const,
label: `@${reference.name}`,
path: reference.path,
description: reference.description ?? referenceDescription(reference),
mention: {
type: "file" as const,
path: reference.path,
content: `@${reference.name}`,
start: 0,
end: 0,
mime: "application/x-directory",
filename: reference.name,
},
})),
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
description: resource.description,
mention: {
type: "file" as const,
path: resource.uri,
content: `@${resource.name}`,
start: 0,
end: 0,
mime: resource.mimeType ?? "text/plain",
filename: resource.name,
url: resource.uri,
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client,
uri: resource.uri,
},
},
resource,
})),
)
const context = createMemo<PromptInputV2Suggestion[]>(() => [
...references(),
...props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent) => ({
id: `agent:${agent.name}`,
kind: "agent" as const,
label: `@${agent.name}`,
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
})),
...resources(),
...recent().map((path) => ({
id: `file:${path}`,
kind: "file" as const,
label: path,
path,
recent: true,
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
})),
])
const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({
id: `custom.${item.name}`,
trigger: item.name,
title: item.name,
description: item.description,
type: "custom" as const,
})),
...command.options
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
.map((item) => ({
id: item.id,
trigger: item.slash!,
title: item.title,
description: item.description,
type: "builtin" as const,
})),
])
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
slashCommands().map((item) => ({
id: item.id,
kind: "command",
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
const controller = createPromptInputV2Controller({
store: () => prompt.capture().store,
state: interaction,
identity: () => prompt.capture(),
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
},
commands,
context,
searchContextFiles: async (query) =>
(await files.searchFilesAndDirectories(query)).map((path) => ({
id: `file:${path}`,
kind: "file",
label: path,
path,
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
props.ref?.(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
return () => command.trigger(selected.id, "slash")
},
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
},
view: {
placeholder: designPlaceholder,
agent:
props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: props.controls.agents.select,
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined,
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
},
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
return controller as PromptInputV2ComposerController
}
function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: PromptInputV2ComposerController["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
}
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
}}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
)
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
const queueFocus = (attempts = 6) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (attempts <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
})
})
}
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
props.controls.session.tabs.setActive("review")
queueFocus()
return
}
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void props.controls.session.tabs.open(tab)
props.controls.session.tabs.setActive(tab)
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = {
export type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean

View file

@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)

View file

@ -0,0 +1,57 @@
import type { useLocal } from "@/context/local"
import type { Prompt, usePrompt } from "@/context/prompt"
import type { PromptInputHistory } from "./history-store"
import type { FollowupDraft } from "./submit"
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export interface PromptInputProps {
class?: string
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}

View file

@ -0,0 +1,47 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}

View file

@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
}

View file

@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@ -31,7 +32,13 @@ let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
context: { items: [] },
})
const prompt = {
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,

View file

@ -12,7 +12,6 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
}
@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))

View file

@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
const current = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
}
const selected = () => current() ?? input.controls().available[0]
const projects = () => {
const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available
@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
}
const select = (project: PromptProject) => {
if (
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== selected()?.server?.key
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
project.server?.key !== current()?.server?.key
) {
input.controls().select(project.worktree, project.server?.key)
}
@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
return {
selected,
empty: () => input.controls().available.length === 0,
projects,
servers,
projectKey,

View file

@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Show when={props.branch}>
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
<PromptGitStatus branch={props.branch} />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
return props.branch
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
)
}

View file

@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@ -19,6 +20,7 @@ export function SortableTabV2(props: {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const sortable = useSortable({
get id() {
return props.tab
@ -39,9 +41,15 @@ export function SortableTabV2(props: {
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
<TooltipV2
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
@ -52,7 +60,7 @@ export function SortableTabV2(props: {
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
</TooltipV2>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}

View file

@ -133,9 +133,10 @@
}
[data-slot="settings-v2-row-description"] {
margin-block: -3.5px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 20px;
color: var(--v2-text-text-muted);
}
@ -270,10 +271,10 @@
}
.settings-v2-provider-description {
margin: 0;
margin-block: -3.5px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 20px;
color: var(--v2-text-text-muted);
}

View file

@ -292,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
if (lastSize?.cols === cols && lastSize?.rows === rows) {
pendingSize = undefined
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
sizeTimer = undefined
return
}
pendingSize = { cols, rows }
@ -317,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
createEffect(() => {
const colors = terminalColors()
const mode = theme.mode() === "dark" ? "dark" : "light"
if (!term) return
setOptionIfSupported(term, "theme", colors)
setOptionIfSupported(term, "colorScheme", mode)
})
createEffect(() => {
@ -396,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
}
_ghostty = g
term = t
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
output = terminalWriter((data, done) =>
t.write(data, () => {
done?.()
@ -595,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
tries = 0
local.onConnect?.()
scheduleSize(t.cols, t.rows)
if (t.getMode(2031)) t.write("\x1b[?996n")
}
const handleMessage = (event: MessageEvent) => {

View file

@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"md:pl-4": !mac(),
}}
>
<ChannelIndicator />
<ChannelIndicator debugTools={props.debugTools} />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show>
@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator() {
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
{["beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
{channel.toUpperCase()}
</div>
)}
</>