mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-20 13:44:27 +00:00
Compare commits
7 Commits
input-clea
...
kit/effect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd292585a4 | ||
|
|
65b370de08 | ||
|
|
88e5830af0 | ||
|
|
f495422fa9 | ||
|
|
080d3b93c6 | ||
|
|
604697f7f8 | ||
|
|
b9de3ad370 |
@@ -1,85 +0,0 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
type Range = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type CommentChipProps = {
|
||||
variant?: "preview" | "full"
|
||||
path: string
|
||||
label: string
|
||||
selection?: Range
|
||||
comment?: string
|
||||
class?: string
|
||||
onOpen?: () => void
|
||||
onRemove?: () => void
|
||||
removeLabel?: string
|
||||
}
|
||||
|
||||
const removeClass =
|
||||
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
const removeIconClass =
|
||||
"absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active"
|
||||
|
||||
export const CommentChip: Component<CommentChipProps> = (props) => {
|
||||
const variant = () => props.variant ?? "preview"
|
||||
const range = () => {
|
||||
const sel = props.selection
|
||||
if (!sel) return
|
||||
const start = Math.min(sel.start, sel.end)
|
||||
const end = Math.max(sel.start, sel.end)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const pad = () => (props.onRemove ? "pr-7" : "pr-2")
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`group relative flex flex-col rounded-[6px] cursor-default bg-background-stronger ${
|
||||
variant() === "full" ? "border border-border-weak-base" : "shadow-xs-border"
|
||||
} ${variant() === "full" ? `pl-2 py-1 ${pad()}` : `pl-2 py-1 h-12 ${pad()}`} ${props.class ?? ""}`}
|
||||
onClick={() => props.onOpen?.()}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{props.label}</span>
|
||||
<Show when={range()}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().start === sel().end ? `:${sel().start}` : `:${sel().start}-${sel().end}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(props.comment ?? "").trim().length > 0}>
|
||||
<div
|
||||
class={`text-base text-text-strong ml-5 ${
|
||||
variant() === "full" ? "whitespace-pre-wrap break-words" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{props.comment}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.onRemove}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${removeClass} group/remove`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onRemove?.()
|
||||
}}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<span class={removeIconClass}>
|
||||
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
@@ -1290,7 +1291,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
images={imageAttachments()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
@@ -1300,12 +1300,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
openImage={(attachment) =>
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
removeImage={removeAttachment}
|
||||
imageRemoveLabel={language.t("prompt.attachment.remove")}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
|
||||
@@ -1,62 +1,30 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
||||
import { PromptImageAttachment } from "./image-attachments"
|
||||
import { CommentChip } from "@/components/comment-chip"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
type PromptContextItem = ContextItem & { key: string }
|
||||
|
||||
type ContextItemsProps = {
|
||||
items: PromptContextItem[]
|
||||
images: ImageAttachmentPart[]
|
||||
active: (item: PromptContextItem) => boolean
|
||||
openComment: (item: PromptContextItem) => void
|
||||
remove: (item: PromptContextItem) => void
|
||||
openImage: (attachment: ImageAttachmentPart) => void
|
||||
removeImage: (id: string) => void
|
||||
imageRemoveLabel: string
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
const seen = new Map<string, number>()
|
||||
let seq = 0
|
||||
|
||||
const rows = createMemo(() => {
|
||||
const all = [
|
||||
...props.items.map((item) => ({ type: "ctx" as const, key: `ctx:${item.key}`, item })),
|
||||
...props.images.map((attachment) => ({ type: "img" as const, key: `img:${attachment.id}`, attachment })),
|
||||
]
|
||||
|
||||
for (const row of all) {
|
||||
if (seen.has(row.key)) continue
|
||||
seen.set(row.key, seq)
|
||||
seq += 1
|
||||
}
|
||||
|
||||
return all.slice().sort((a, b) => (seen.get(a.key) ?? 0) - (seen.get(b.key) ?? 0))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={rows().length > 0}>
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={rows()}>
|
||||
{(row) => {
|
||||
if (row.type === "img") {
|
||||
return (
|
||||
<PromptImageAttachment
|
||||
attachment={row.attachment}
|
||||
onOpen={props.openImage}
|
||||
onRemove={props.removeImage}
|
||||
removeLabel={props.imageRemoveLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const directory = getDirectory(row.item.path)
|
||||
const filename = getFilename(row.item.path)
|
||||
const label = getFilenameTruncated(row.item.path, 14)
|
||||
<For each={props.items}>
|
||||
{(item) => {
|
||||
const directory = getDirectory(item.path)
|
||||
const filename = getFilename(item.path)
|
||||
const label = getFilenameTruncated(item.path, 14)
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -70,26 +38,46 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
class="shrink-0"
|
||||
>
|
||||
<CommentChip
|
||||
variant="preview"
|
||||
path={row.item.path}
|
||||
label={label}
|
||||
selection={
|
||||
row.item.selection
|
||||
? {
|
||||
start: row.item.selection.startLine,
|
||||
end: row.item.selection.endLine,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comment={row.item.comment}
|
||||
class="max-w-[200px]"
|
||||
onOpen={() => props.openComment(row.item)}
|
||||
onRemove={() => props.remove(row.item)}
|
||||
removeLabel={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||
"bg-background-stronger": !selected,
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||
|
||||
type PromptImageAttachmentsProps = {
|
||||
@@ -11,68 +9,50 @@ type PromptImageAttachmentsProps = {
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
type PromptImageAttachmentProps = {
|
||||
attachment: ImageAttachmentPart
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
const fallbackClass =
|
||||
"size-12 rounded-[6px] bg-background-stronger flex items-center justify-center shadow-xs-border cursor-default"
|
||||
const imageClass = "size-12 rounded-[6px] object-cover shadow-xs-border"
|
||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||
const imageClass =
|
||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
const removeClass =
|
||||
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
const removeIconClass =
|
||||
"absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active"
|
||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<>
|
||||
{props.attachments.map((attachment) => (
|
||||
<PromptImageAttachment
|
||||
attachment={attachment}
|
||||
onOpen={props.onOpen}
|
||||
onRemove={props.onRemove}
|
||||
removeLabel={props.removeLabel}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class={imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class={nameClass}>
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptImageAttachment: Component<PromptImageAttachmentProps> = (props) => {
|
||||
return (
|
||||
<Tooltip value={props.attachment.filename} placement="top" gutter={6} class="shrink-0">
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={props.attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<FileIcon node={{ path: props.attachment.filename, type: "file" }} class="size-5" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.attachment.dataUrl}
|
||||
alt={props.attachment.filename}
|
||||
class={imageClass}
|
||||
onClick={() => props.onOpen(props.attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class={`${removeClass} group/remove`}
|
||||
onClick={() => props.onRemove(props.attachment.id)}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<span class={removeIconClass}>
|
||||
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
||||
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.restore": "استعادة",
|
||||
"session.revertDock.restore": "استعادة الرسالة",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
|
||||
@@ -481,7 +481,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||
"session.revertDock.restore": "Restaurar",
|
||||
"session.revertDock.restore": "Restaurar mensagem",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
|
||||
@@ -536,7 +536,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||
"session.revertDock.restore": "Vrati",
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
||||
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
||||
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
||||
"session.revertDock.restore": "Gendan",
|
||||
"session.revertDock.restore": "Gendan besked",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
|
||||
@@ -489,7 +489,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
||||
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||
"session.revertDock.restore": "Wiederherstellen",
|
||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
||||
"session.new.title": "Baue, was du willst",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
|
||||
@@ -561,7 +561,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||
"session.revertDock.expand": "Expand rolled back messages",
|
||||
"session.revertDock.restore": "Restore",
|
||||
"session.revertDock.restore": "Restore message",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
|
||||
@@ -537,7 +537,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
||||
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
||||
"session.revertDock.expand": "Expandir mensajes revertidos",
|
||||
"session.revertDock.restore": "Restaurar",
|
||||
"session.revertDock.restore": "Restaurar mensaje",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
|
||||
@@ -486,7 +486,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} messages annulés",
|
||||
"session.revertDock.collapse": "Réduire les messages annulés",
|
||||
"session.revertDock.expand": "Développer les messages annulés",
|
||||
"session.revertDock.restore": "Restaurer",
|
||||
"session.revertDock.restore": "Restaurer le message",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
|
||||
@@ -478,7 +478,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
|
||||
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
|
||||
"session.revertDock.expand": "ロールバックされたメッセージを展開",
|
||||
"session.revertDock.restore": "復元",
|
||||
"session.revertDock.restore": "メッセージを復元",
|
||||
"session.new.title": "何でも作る",
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
|
||||
@@ -480,7 +480,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
|
||||
"session.revertDock.collapse": "롤백된 메시지 접기",
|
||||
"session.revertDock.expand": "롤백된 메시지 펼치기",
|
||||
"session.revertDock.restore": "복원",
|
||||
"session.revertDock.restore": "메시지 복원",
|
||||
"session.new.title": "무엇이든 만들기",
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
|
||||
@@ -537,7 +537,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
|
||||
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
|
||||
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
|
||||
"session.revertDock.restore": "Gjenopprett",
|
||||
"session.revertDock.restore": "Gjenopprett melding",
|
||||
|
||||
"session.new.title": "Bygg hva som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
|
||||
@@ -479,7 +479,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
|
||||
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
|
||||
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
|
||||
"session.revertDock.restore": "Przywróć",
|
||||
"session.revertDock.restore": "Przywróć wiadomość",
|
||||
"session.new.title": "Zbuduj cokolwiek",
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
|
||||
@@ -534,7 +534,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
|
||||
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
|
||||
"session.revertDock.expand": "Развернуть возвращённые сообщения",
|
||||
"session.revertDock.restore": "Восстановить",
|
||||
"session.revertDock.restore": "Восстановить сообщение",
|
||||
|
||||
"session.new.title": "Создавайте что угодно",
|
||||
"session.new.worktree.main": "Основная ветка",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.restore": "กู้คืน",
|
||||
"session.revertDock.restore": "กู้คืนข้อความ",
|
||||
|
||||
"session.new.title": "สร้างอะไรก็ได้",
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
|
||||
@@ -541,7 +541,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
|
||||
"session.revertDock.collapse": "Geri alınan mesajları daralt",
|
||||
"session.revertDock.expand": "Geri alınan mesajları genişlet",
|
||||
"session.revertDock.restore": "Geri yükle",
|
||||
"session.revertDock.restore": "Mesajı geri yükle",
|
||||
|
||||
"session.new.title": "İstediğini yap",
|
||||
"session.new.worktree.main": "Ana dal",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 条已回滚消息",
|
||||
"session.revertDock.collapse": "折叠已回滚消息",
|
||||
"session.revertDock.expand": "展开已回滚消息",
|
||||
"session.revertDock.restore": "恢复",
|
||||
"session.revertDock.restore": "恢复消息",
|
||||
"session.new.title": "构建任何东西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
|
||||
@@ -527,7 +527,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 則已回復訊息",
|
||||
"session.revertDock.collapse": "收合已回復訊息",
|
||||
"session.revertDock.expand": "展開已回復訊息",
|
||||
"session.revertDock.restore": "還原",
|
||||
"session.revertDock.restore": "還原訊息",
|
||||
|
||||
"session.new.title": "建構任何東西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
|
||||
@@ -1324,22 +1324,9 @@ export default function Page() {
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const tag = (mime: string | undefined) => {
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime?.startsWith("image/")) return "image"
|
||||
return "file"
|
||||
}
|
||||
|
||||
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||
|
||||
const line = (id: string) => {
|
||||
const text = draft(id)
|
||||
.map((part) => {
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
return part.content
|
||||
})
|
||||
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
@@ -1407,7 +1394,7 @@ export default function Page() {
|
||||
const followupText = (item: FollowupDraft) => {
|
||||
const text = item.prompt
|
||||
.map((part) => {
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
return part.content
|
||||
|
||||
@@ -46,14 +46,6 @@ export function SessionComposerRegion(props: {
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
|
||||
const tag = (mime: string) => {
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
return "file"
|
||||
}
|
||||
|
||||
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
|
||||
const previewPrompt = () =>
|
||||
@@ -62,7 +54,7 @@ export function SessionComposerRegion(props: {
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
|
||||
@@ -259,26 +259,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } f
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -14,7 +15,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -28,7 +29,6 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { CommentChip } from "@/components/comment-chip"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -960,36 +960,40 @@ export function MessageTimeline(props: {
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="w-full overflow-visible">
|
||||
<div class="overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<Show when={comment()}>
|
||||
{(c) => (
|
||||
<CommentChip
|
||||
variant="full"
|
||||
path={c().path}
|
||||
label={getFilenameTruncated(c().path, 14)}
|
||||
selection={
|
||||
c().selection
|
||||
? {
|
||||
start: c().selection!.startLine,
|
||||
end: c().selection!.endLine,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comment={c().comment}
|
||||
class="max-w-[260px]"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<Show when={comment()}>
|
||||
{(c) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: c().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(c().path)}</span>
|
||||
<Show when={c().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{c().comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ Done now:
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [ ] `Plugin`
|
||||
- [x] `Plugin`
|
||||
- [ ] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import type { ZodObject, ZodRawShape } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace BusEvent {
|
||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
payload: any
|
||||
payload: { type: string; properties: Record<string, unknown> }
|
||||
},
|
||||
]
|
||||
}>()
|
||||
|
||||
@@ -124,7 +124,7 @@ export namespace Workspace {
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
payload: event as { type: string; properties: Record<string, unknown> },
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { Question } from "@/question"
|
||||
import { Skill } from "@/skill/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
@@ -26,6 +27,7 @@ export type InstanceServices =
|
||||
| File.Service
|
||||
| Skill.Service
|
||||
| Snapshot.Service
|
||||
| Plugin.Service
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
@@ -33,7 +35,7 @@ export type InstanceServices =
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
function lookup(_key: string): Layer.Layer<InstanceServices> {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(Question.layer),
|
||||
@@ -46,6 +48,7 @@ function lookup(_key: string) {
|
||||
Layer.fresh(File.layer),
|
||||
Layer.fresh(Skill.defaultLayer),
|
||||
Layer.fresh(Snapshot.defaultLayer),
|
||||
Layer.fresh(Plugin.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
||||
import { Effect, Fiber, Layer, ServiceMap } from "effect"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -19,126 +21,173 @@ export namespace Plugin {
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: Instance.directory,
|
||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||
? {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
project: Instance.project,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
$: Bun.$,
|
||||
}
|
||||
export interface Interface {
|
||||
readonly trigger: <
|
||||
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(
|
||||
name: Name,
|
||||
input: Input,
|
||||
output: Output,
|
||||
) => Effect.Effect<Output>
|
||||
readonly list: () => Effect.Effect<Hooks[]>
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = await plugin(input).catch((err) => {
|
||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
const load = Effect.fn("Plugin.load")(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: instance.directory,
|
||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||
? {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
project: instance.project,
|
||||
worktree: instance.worktree,
|
||||
directory: instance.directory,
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = await plugin(input).catch((err) => {
|
||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||
})
|
||||
if (init) hooks.push(init)
|
||||
}
|
||||
|
||||
let plugins = config.plugin ?? []
|
||||
if (plugins.length) await Config.waitForDependencies()
|
||||
|
||||
for (let plugin of plugins) {
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!plugin) continue
|
||||
}
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if (init) hooks.push(init)
|
||||
}
|
||||
|
||||
let plugins = config.plugin ?? []
|
||||
if (plugins.length) await Config.waitForDependencies()
|
||||
const loadFiber = yield* load().pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
for (let plugin of plugins) {
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!plugin) continue
|
||||
}
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
const trigger = Effect.fn("Plugin.trigger")(function* <
|
||||
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output) {
|
||||
if (!name) return output
|
||||
yield* Fiber.join(loadFiber)
|
||||
yield* Effect.promise(async () => {
|
||||
for (const hook of hooks) {
|
||||
const fn = hook[name]
|
||||
if (!fn) continue
|
||||
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
|
||||
// give up.
|
||||
// try-counter: 2
|
||||
await fn(input, output)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
return output
|
||||
})
|
||||
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return hooks
|
||||
})
|
||||
|
||||
const init = Effect.fn("Plugin.init")(function* () {
|
||||
yield* Fiber.join(loadFiber)
|
||||
yield* Effect.promise(async () => {
|
||||
const config = await Config.get()
|
||||
for (const hook of hooks) {
|
||||
await (hook as any).config?.(config)
|
||||
}
|
||||
Bus.subscribeAll(async (input) => {
|
||||
for (const hook of hooks) {
|
||||
hook["event"]?.({
|
||||
event: input,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hooks,
|
||||
input,
|
||||
}
|
||||
})
|
||||
return Service.of({ trigger, list, init })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function trigger<
|
||||
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||
if (!name) return output
|
||||
for (const hook of await state().then((x) => x.hooks)) {
|
||||
const fn = hook[name]
|
||||
if (!fn) continue
|
||||
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
|
||||
// give up.
|
||||
// try-counter: 2
|
||||
await fn(input, output)
|
||||
}
|
||||
return output
|
||||
return runPromiseInstance(Service.use((svc) => svc.trigger(name, input, output)))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => x.hooks)
|
||||
export async function list(): Promise<Hooks[]> {
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
const hooks = await state().then((x) => x.hooks)
|
||||
const config = await Config.get()
|
||||
for (const hook of hooks) {
|
||||
// @ts-expect-error this is because we haven't moved plugin to sdk v2
|
||||
await hook.config?.(config)
|
||||
}
|
||||
Bus.subscribeAll(async (input) => {
|
||||
const hooks = await state().then((x) => x.hooks)
|
||||
for (const hook of hooks) {
|
||||
hook["event"]?.({
|
||||
event: input,
|
||||
})
|
||||
}
|
||||
})
|
||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import path from "path"
|
||||
import { Deferred, Effect, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcher service. */
|
||||
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
function on(evt: BusUpdate) {
|
||||
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||
if (done) return
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||
if (!check(evt.payload.properties)) return
|
||||
hit(evt.payload.properties)
|
||||
}
|
||||
if (!check(evt.properties)) return
|
||||
hit(evt.properties)
|
||||
})
|
||||
|
||||
function cleanup() {
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
GlobalBus.off("event", on)
|
||||
unsub()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-weak-base);
|
||||
border-color: var(--border-strong-base);
|
||||
}
|
||||
|
||||
&[data-clickable] {
|
||||
@@ -62,11 +62,9 @@
|
||||
}
|
||||
|
||||
&[data-type="file"] {
|
||||
width: fit-content;
|
||||
max-width: min(260px, 100%);
|
||||
width: min(220px, 100%);
|
||||
height: 48px;
|
||||
padding: 0 18px 0 10px;
|
||||
background: var(--background-stronger);
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +89,7 @@
|
||||
}
|
||||
|
||||
[data-slot="user-message-attachment-file"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,12 +104,10 @@
|
||||
|
||||
[data-slot="user-message-attachment-name"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -900,12 +900,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
|
||||
const text = createMemo(() => textPart()?.text || "")
|
||||
|
||||
const shown = createMemo(() => {
|
||||
const value = text()
|
||||
if (!value.trim()) return ""
|
||||
return value
|
||||
})
|
||||
|
||||
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
|
||||
|
||||
const attachments = createMemo(() => files().filter(attached))
|
||||
@@ -1001,11 +995,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={shown()}>
|
||||
<Show when={text()}>
|
||||
<>
|
||||
<div data-slot="user-message-body">
|
||||
<div data-slot="user-message-text">
|
||||
<HighlightedText text={shown()} references={inlineFiles()} agents={agents()} />
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
|
||||
Reference in New Issue
Block a user