feat: prompt styles

This commit is contained in:
Aaron Iker
2026-02-01 22:42:20 +01:00
parent 86a82a0cdc
commit 9e89a8a97a
3 changed files with 103 additions and 63 deletions

View File

@@ -90,9 +90,10 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: { export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string provider?: string
children?: JSX.Element children?: JSX.Element | ((open: boolean) => JSX.Element)
triggerAs?: T triggerAs?: T
triggerProps?: ComponentProps<T> triggerProps?: ComponentProps<T>,
gutter?: number
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
@@ -175,14 +176,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
}} }}
modal={false} modal={false}
placement="top-start" placement="top-start"
gutter={8} gutter={props.gutter ?? 8}
> >
<Kobalte.Trigger <Kobalte.Trigger
ref={(el) => setStore("trigger", el)} ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"} as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)} {...(props.triggerProps as any)}
> >
{props.children} {typeof props.children === "function" ? props.children(store.open) : props.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content

View File

@@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider" import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -42,6 +44,7 @@ import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@@ -922,7 +925,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.abort({ .abort({
sessionID, sessionID,
}) })
.catch(() => {}) .catch(() => { })
} }
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -1252,7 +1255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput() clearInput()
client.session client.session
.shell({ .shell({
sessionID: session.id, sessionID: session?.id || "",
agent, agent,
model, model,
command: text, command: text,
@@ -1275,7 +1278,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput() clearInput()
client.session client.session
.command({ .command({
sessionID: session.id, sessionID: session?.id || "",
command: commandName, command: commandName,
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
@@ -1348,18 +1351,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const contextParts: Array< const contextParts: Array<
| { | {
id: string id: string
type: "text" type: "text"
text: string text: string
synthetic?: boolean synthetic?: boolean
} }
| { | {
id: string id: string
type: "file" type: "file"
mime: string mime: string
url: string url: string
filename?: string filename?: string
} }
> = [] > = []
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
@@ -1431,13 +1434,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({ const optimisticParts = requestParts.map((part) => ({
...part, ...part,
sessionID: session.id, sessionID: session?.id || "",
messageID, messageID,
})) as unknown as Part[] })) as unknown as Part[]
const optimisticMessage: Message = { const optimisticMessage: Message = {
id: messageID, id: messageID,
sessionID: session.id, sessionID: session?.id || "",
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent, agent,
@@ -1448,9 +1451,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set( sync.set(
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (!messages) { if (!messages) {
draft.message[session.id] = [optimisticMessage] draft.message[session?.id || ""] = [optimisticMessage]
} else { } else {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage) messages.splice(result.index, 0, optimisticMessage)
@@ -1466,9 +1469,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1]( globalSync.child(sessionDirectory)[1](
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (!messages) { if (!messages) {
draft.message[session.id] = [optimisticMessage] draft.message[session?.id || ""] = [optimisticMessage]
} else { } else {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage) messages.splice(result.index, 0, optimisticMessage)
@@ -1485,7 +1488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set( sync.set(
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (messages) { if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
@@ -1498,7 +1501,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1]( globalSync.child(sessionDirectory)[1](
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (messages) { if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
@@ -1519,15 +1522,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory) const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "busy" }) sync.set("session_status", session?.id, { type: "busy" })
} }
const controller = new AbortController() const controller = new AbortController()
const cleanup = () => { const cleanup = () => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session?.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
for (const item of commentItems) { for (const item of commentItems) {
@@ -1544,7 +1547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreInput() restoreInput()
} }
pending.set(session.id, { abort: controller, cleanup }) pending.set(session?.id || "", { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@@ -1572,7 +1575,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}) })
pending.delete(session.id) pending.delete(session?.id || "")
if (controller.signal.aborted) return false if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message) if (result.status === "failed") throw new Error(result.message)
return true return true
@@ -1582,7 +1585,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree() const ok = await waitForWorktree()
if (!ok) return if (!ok) return
await client.session.prompt({ await client.session.prompt({
sessionID: session.id, sessionID: session?.id || "",
agent, agent,
model, model,
messageID, messageID,
@@ -1592,9 +1595,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
void send().catch((err) => { void send().catch((err) => {
pending.delete(session.id) pending.delete(session?.id || "")
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session?.id, { type: "idle" })
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1616,6 +1619,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const currrentModelVariant = createMemo(() => {
const modelVariant = local.model.variant.current() ?? ""
return modelVariant === "xhigh"
? "xHigh"
: modelVariant.length > 0
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
: "Default"
})
const reasoningPercentage = createMemo(() => {
const variants = local.model.variant.list()
const current = local.model.variant.current()
const totalEntries = variants.length + 1
if (totalEntries <= 2 || current === "Default") {
return 0
}
const currentIndex = current ? variants.indexOf(current) + 1 : 0
return ((currentIndex + 1) / totalEntries) * 100
}, [local.model.variant])
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3"> <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}> <Show when={store.popover}>
@@ -1668,7 +1693,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</> </>
} }
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap"> <span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name} @{(item as { type: "agent"; name: string }).name}
</span> </span>
@@ -1729,9 +1754,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
> >
<Show when={store.dragging}> <Show when={store.dragging}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak"> <div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name="photo" class="size-8" /> <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span> <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
</div> </div>
</div> </div>
@@ -1770,7 +1795,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
> >
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
<div class="flex items-center text-11-regular min-w-0 font-medium"> <div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}> <Show when={item.selection}>
@@ -1787,7 +1812,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
icon="close-small" icon="close-small"
variant="ghost" variant="ghost"
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID) if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1817,7 +1842,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
when={attachment.mime.startsWith("image/")} when={attachment.mime.startsWith("image/")}
fallback={ fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" /> <Icon name="folder" size="normal" class="size-6 text-text-base" />
</div> </div>
} }
> >
@@ -1891,7 +1916,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<div class="relative p-3 flex items-center justify-between"> <div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0.5"> <div class="flex items-center justify-start gap-2">
<Switch> <Switch>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6"> <div class="flex items-center gap-2 px-2 h-6">
@@ -1912,6 +1937,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize" class="capitalize"
variant="ghost" variant="ghost"
gutter={12}
/> />
</TooltipKeybind> </TooltipKeybind>
<Show <Show
@@ -1922,12 +1948,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}> <Button
as="div"
variant="ghost"
class="px-2"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show> </Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
<Icon name="chevron-down" size="small" /> <MorphChevron expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")} />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
} }
@@ -1937,12 +1968,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}> <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
<Show when={local.model.current()?.provider?.id}> {(open) => (
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <>
</Show> <Show when={local.model.current()?.provider?.id}>
{local.model.current()?.name ?? language.t("dialog.model.select.title")} <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
<Icon name="chevron-down" size="small" /> </Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={open} class="text-text-weak" />
</>
)}
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
@@ -1955,10 +1990,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button <Button
data-action="model-variant-cycle" data-action="model-variant-cycle"
variant="ghost" variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular" class="text-text-strong text-12-regular"
onClick={() => local.model.variant.cycle()} onClick={() => local.model.variant.cycle()}
> >
{local.model.variant.current() ?? language.t("common.default")} <Show when={local.model.variant.list().length > 1}>
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
</Show>
<CycleLabel value={currrentModelVariant()} />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
@@ -1972,7 +2010,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost" variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{ classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "_hidden group-hover/prompt-input:flex items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}} }}
@@ -1994,7 +2032,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match> </Match>
</Switch> </Switch>
</div> </div>
<div class="flex items-center gap-3 absolute right-3 bottom-3"> <div class="flex items-center gap-1 absolute right-3 bottom-3">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -2006,18 +2044,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5 mr-1.5">
<SessionContextUsage /> <SessionContextUsage />
<Show when={store.mode === "normal"}> <Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}> <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
class="size-6" size="small"
class="px-1"
onClick={() => fileInputRef.click()} onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")} aria-label={language.t("prompt.action.attachFile")}
> >
<Icon name="photo" class="size-4.5" /> <Icon name="photo" class="size-6 text-icon-base" />
</Button> </Button>
</Tooltip> </Tooltip>
</Show> </Show>
@@ -2036,7 +2075,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}> <Match when={true}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span> <span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" /> <Icon name="enter" size="normal" class="text-icon-base" />
</div> </div>
</Match> </Match>
</Switch> </Switch>
@@ -2047,7 +2086,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()} disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-6 w-5.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>

View File

@@ -80,13 +80,13 @@ const icons = {
export interface IconProps extends ComponentProps<"svg"> { export interface IconProps extends ComponentProps<"svg"> {
name: keyof typeof icons name: keyof typeof icons
size?: "small" | "normal" | "medium" | "large" size?: "small" | "normal" | "medium" | "large" | number
} }
export function Icon(props: IconProps) { export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
return ( return (
<div data-component="icon" data-size={local.size || "normal"}> <div data-component="icon" data-size={typeof local.size !== 'number' ? local.size || "normal" : `size-[${local.size}px]`}>
<svg <svg
data-slot="icon-svg" data-slot="icon-svg"
classList={{ classList={{