Merge branch 'dev' into llm-centralization

This commit is contained in:
Dax Raad 2025-12-14 16:48:51 -05:00
commit a5914f4d7c
56 changed files with 772 additions and 279 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.152",
"version": "1.0.153",
"description": "",
"type": "module",
"exports": {
@ -37,6 +37,7 @@
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",

View file

@ -1,18 +1,7 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import {
createEffect,
on,
Component,
Show,
For,
onMount,
onCleanup,
Switch,
Match,
createSignal,
createMemo,
} from "solid-js"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
@ -81,22 +70,85 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [store, setStore] = createStore<{
popoverIsOpen: boolean
historyIndex: number
savedPrompt: Prompt | null
placeholder: number
}>({
popoverIsOpen: false,
historyIndex: -1,
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
})
const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length))
const MAX_HISTORY = 100
const [history, setHistory] = makePersisted(
createStore<{
entries: Prompt[]
}>({
entries: [],
}),
{
name: "prompt-history.v1",
},
)
onMount(() => {
const interval = setInterval(() => {
setPlaceholder((prev) => (prev + 1) % PLACEHOLDERS.length)
}, 6500)
onCleanup(() => clearInterval(interval))
})
const clonePromptParts = (prompt: Prompt): Prompt =>
prompt.map((part) =>
part.type === "text"
? { ...part }
: {
...part,
selection: part.selection ? { ...part.selection } : undefined,
},
)
const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(prompt)
session.prompt.set(prompt, length)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, length)
})
}
const getCaretLineState = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false }
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const editorRect = editorRef.getBoundingClientRect()
const style = window.getComputedStyle(editorRef)
const paddingTop = parseFloat(style.paddingTop) || 0
const paddingBottom = parseFloat(style.paddingBottom) || 0
let lineHeight = parseFloat(style.lineHeight)
if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16
const scrollTop = editorRef.scrollTop
let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop
if (!Number.isFinite(relativeTop)) relativeTop = scrollTop
relativeTop = Math.max(0, relativeTop)
let caretHeight = rect.height
if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight
const relativeBottom = relativeTop + caretHeight
const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom)
const threshold = Math.max(2, lineHeight / 2)
return {
collapsed: selection.isCollapsed,
onFirstLine: relativeTop <= threshold,
onLastLine: contentHeight - relativeBottom <= threshold,
}
}
createEffect(() => {
session.id
editorRef.focus()
if (session.id) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
}, 6500)
onCleanup(() => clearInterval(interval))
})
const isFocused = createFocusSignal(() => editorRef)
@ -129,17 +181,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
}
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
items: local.file.searchFilesAndDirectories,
key: (x) => x,
onSelect: handleFileSelect,
})
createEffect(() => {
local.model.recent()
refetch()
})
createEffect(
on(
() => session.prompt.current(),
@ -221,6 +268,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popoverIsOpen", false)
}
if (store.historyIndex >= 0) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
session.prompt.set(rawParts, cursorPosition)
}
@ -296,12 +348,100 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: session.id!,
})
const addToHistory = (prompt: Prompt) => {
const text = prompt
.map((p) => p.content)
.join("")
.trim()
if (!text) return
const entry = clonePromptParts(prompt)
const lastEntry = history.entries[0]
if (lastEntry) {
const lastText = lastEntry.map((p) => p.content).join("")
if (lastText === text) return
}
setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
}
const navigateHistory = (direction: "up" | "down") => {
const entries = history.entries
const current = store.historyIndex
if (direction === "up") {
if (entries.length === 0) return false
if (current === -1) {
setStore("savedPrompt", clonePromptParts(session.prompt.current()))
setStore("historyIndex", 0)
applyHistoryPrompt(entries[0], "start")
return true
}
if (current < entries.length - 1) {
const next = current + 1
setStore("historyIndex", next)
applyHistoryPrompt(entries[next], "start")
return true
}
return false
}
if (current > 0) {
const next = current - 1
setStore("historyIndex", next)
applyHistoryPrompt(entries[next], "end")
return true
}
if (current === 0) {
setStore("historyIndex", -1)
const saved = store.savedPrompt
if (saved) {
applyHistoryPrompt(saved, "end")
setStore("savedPrompt", null)
return true
}
applyHistoryPrompt(DEFAULT_PROMPT, "end")
return true
}
return false
}
const handleKeyDown = (event: KeyboardEvent) => {
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
onKeyDown(event)
event.preventDefault()
return
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
if (!collapsed) return
const cursorPos = getCursorPosition(editorRef)
const textLength = promptLength(session.prompt.current())
const inHistory = store.historyIndex >= 0
const isStart = cursorPos === 0
const isEnd = cursorPos === textLength
const atAbsoluteStart = onFirstLine && isStart
const atAbsoluteEnd = onLastLine && isEnd
const allowUp = (inHistory && isEnd) || atAbsoluteStart
const allowDown = (inHistory && isStart) || atAbsoluteEnd
if (event.key === "ArrowUp") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
}
return
}
if (!allowDown) return
if (navigateHistory("down")) {
event.preventDefault()
}
return
}
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
@ -323,6 +463,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
addToHistory(prompt)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
let existing = session.info()
if (!existing) {
const created = await sdk.client.session.create()
@ -461,7 +605,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<Show when={!session.prompt.dirty()}>
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Ask anything... "{PLACEHOLDERS[placeholder()]}"
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
</div>
</Show>
</div>
@ -507,6 +651,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {

View file

@ -1,8 +1,9 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/session"
import { usePrefersDark } from "@solid-primitives/media"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
const prefersDark = usePrefersDark()
onMount(async () => {
ghostty = await Ghostty.load()
@ -31,10 +33,17 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: "TX-02, monospace",
allowTransparency: true,
theme: {
background: "#191515",
foreground: "#d4d4d4",
},
theme: prefersDark()
? {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
}
: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
scrollback: 10_000,
ghostty,
})

View file

@ -189,11 +189,13 @@ export default function Layout(props: ParentProps) {
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class="relative size-6 shrink-0">
<Avatar
fallback={name()}
src={props.project.icon?.url}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class={`size-full ${props.class ?? ""}`}
style={
@ -318,22 +320,20 @@ export default function Layout(props: ParentProps) {
)
}
return (
<A
href={`${slug()}/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
<div
class="group/session relative w-full pl-4 pr-1 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<Tooltip placement="right" value={session.title}>
<div
class="relative w-full pl-4 pr-1 py-1 rounded-md
group-[.active]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
<Tooltip placement="right" value={session.title} gutter={10}>
<A
href={`${slug()}/session/${session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
<div class="shrink-0 group-hover/session:hidden mr-1">
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<Switch>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
@ -358,12 +358,6 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
</div>
<div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1">
{/* <IconButton icon="dot-grid" variant="ghost" /> */}
<Tooltip placement="right" value="Archive session">
<IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
</Tooltip>
</div>
</div>
<Show when={session.summary?.files}>
<div class="flex justify-between items-center self-stretch">
@ -371,29 +365,40 @@ export default function Layout(props: ParentProps) {
<Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</Show>
</div>
</A>
</Tooltip>
</A>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
{/* <IconButton icon="dot-grid" variant="ghost" /> */}
<Tooltip placement="right" value="Archive session">
<IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
</Tooltip>
</div>
</div>
)
}}
</For>
<Show when={sessions().length === 0}>
<A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
<Tooltip placement="right" value="New session">
<div
class="relative w-full pl-4 pr-1 py-1 rounded-md
group-[.active]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
New session
</span>
</div>
<div
class="group/session relative w-full pl-4 pr-1 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch w-full">
<div class="flex-1 min-w-0">
<Tooltip placement="right" value="New session">
<A
href={`${slug()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
New session
</span>
</div>
</A>
</Tooltip>
</div>
</Tooltip>
</A>
</div>
</div>
</Show>
</nav>
</Collapsible.Content>