Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton
771f1918b5 Merge branch 'dev' into kit/effectify-pty 2026-03-20 09:17:37 -04:00
Kit Langton
6a3cb06c6c effectify Pty service, add runSyncInstance helper 2026-03-19 21:18:15 -04:00
31 changed files with 442 additions and 525 deletions

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -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>
)
}}

View File

@@ -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>
)
}

View File

@@ -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}})",

View File

@@ -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}})",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}})",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}})",

View File

@@ -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}})",

View File

@@ -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}})",

View File

@@ -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",

View File

@@ -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}})",

View File

@@ -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": "Основная ветка",

View File

@@ -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": "สาขาหลัก",

View File

@@ -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",

View File

@@ -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}}",

View File

@@ -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": "主分支",

View File

@@ -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

View File

@@ -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("")

View File

@@ -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={

View File

@@ -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>

View File

@@ -128,7 +128,7 @@ Still open and likely worth migrating:
- [ ] `Plugin`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [x] `Pty`
- [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus`

View File

@@ -4,6 +4,7 @@ import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { PermissionNext } from "@/permission"
import { Pty } from "@/pty"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth"
@@ -24,6 +25,7 @@ export type InstanceServices =
| FileTime.Service
| Format.Service
| File.Service
| Pty.Service
| Skill.Service
| Snapshot.Service
@@ -44,6 +46,7 @@ function lookup(_key: string) {
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
Layer.fresh(Format.layer),
Layer.fresh(File.layer),
Layer.fresh(Pty.layer),
Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx))

View File

@@ -18,6 +18,10 @@ export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceSer
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function runSyncInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runSync(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}

View File

@@ -1,13 +1,15 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance, runSyncInstance } from "@/effect/runtime"
import { type IPty } from "bun-pty"
import z from "zod"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
export namespace Pty {
const log = Log.create({ service: "pty" })
@@ -90,232 +92,284 @@ export namespace Pty {
subscribers: Map<unknown, Socket>
}
const state = Instance.state(
() => new Map<PtyID, ActiveSession>(),
async (sessions) => {
for (const session of sessions.values()) {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
readonly remove: (id: PtyID) => Effect.Effect<void>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const sessions = new Map<PtyID, ActiveSession>()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {}
}
}
sessions.clear()
}),
)
const removeSession = (id: PtyID) => {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
log.info("removing session", { id })
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {}
}
session.subscribers.clear()
Bus.publish(Event.Deleted, { id: session.info.id })
}
const list = Effect.fn("Pty.list")(function* () {
return Array.from(sessions.values()).map((s) => s.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return sessions.get(id)?.info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
return yield* Effect.promise(async () => {
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (command.endsWith("sh")) {
args.push("-l")
}
const cwd = input.cwd || instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shellEnv.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty()
const ptyProcess = spawn(command, args, {
name: "xterm-256color",
cwd,
env,
})
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: ptyProcess.pid,
} as const
const session: ActiveSession = {
info,
process: ptyProcess,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
}
sessions.set(id, session)
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
ptyProcess.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
removeSession(id)
})
Bus.publish(Event.Created, { info })
return info
})
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = sessions.get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
Bus.publish(Event.Updated, { info: session.info })
return session.info
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
removeSession(id)
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = sessions.get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = sessions.get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = sessions.get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
const cleanup = () => {
session.subscribers.delete(connectionKey)
}
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
// ignore
cleanup()
ws.close()
return
}
}
}
sessions.clear()
},
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
}),
)
// Sync facades
export function list() {
return Array.from(state().values()).map((s) => s.info)
return runSyncInstance(Service.use((svc) => svc.list()))
}
export function get(id: PtyID) {
return state().get(id)?.info
}
export async function create(input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (command.endsWith("sh")) {
args.push("-l")
}
const cwd = input.cwd || Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shellEnv.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty()
const ptyProcess = spawn(command, args, {
name: "xterm-256color",
cwd,
env,
})
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: ptyProcess.pid,
} as const
const session: ActiveSession = {
info,
process: ptyProcess,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
ptyProcess.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
remove(id)
}),
)
Bus.publish(Event.Created, { info })
return info
}
export async function update(id: PtyID, input: UpdateInput) {
const session = state().get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
Bus.publish(Event.Updated, { info: session.info })
return session.info
}
export async function remove(id: PtyID) {
const session = state().get(id)
if (!session) return
state().delete(id)
log.info("removing session", { id })
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Deleted, { id: session.info.id })
return runSyncInstance(Service.use((svc) => svc.get(id)))
}
export function resize(id: PtyID, cols: number, rows: number) {
const session = state().get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
runSyncInstance(Service.use((svc) => svc.resize(id, cols, rows)))
}
export function write(id: PtyID, data: string) {
const session = state().get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
runSyncInstance(Service.use((svc) => svc.write(id, data)))
}
export function connect(id: PtyID, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
return runSyncInstance(Service.use((svc) => svc.connect(id, ws, cursor)))
}
// Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
// Async facades
export async function create(input: CreateInput) {
return runPromiseInstance(Service.use((svc) => svc.create(input)))
}
// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
export async function update(id: PtyID, input: UpdateInput) {
return runPromiseInstance(Service.use((svc) => svc.update(id, input)))
}
const cleanup = () => {
session.subscribers.delete(connectionKey)
}
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
export async function remove(id: PtyID) {
return runPromiseInstance(Service.use((svc) => svc.remove(id)))
}
}

View File

@@ -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);
}

View File

@@ -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">