Commit - use types from SDK

This commit is contained in:
Shantur Rathore
2025-11-11 21:06:37 +00:00
parent 063a11db76
commit 89dbe43d87
17 changed files with 691 additions and 244 deletions

View File

@@ -2,6 +2,7 @@ import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSi
import { Toaster } from "solid-toast"
import type { Session } from "./types/session"
import type { Attachment } from "./types/attachment"
import type { SDKPart, ClientPart } from "./types/message"
import FolderSelectionView from "./components/folder-selection-view"
import InstanceWelcomeView from "./components/instance-welcome-view"
import CommandPalette from "./components/command-palette"
@@ -99,12 +100,12 @@ const SessionView: Component<{
return null
}
const textParts = targetMessage.parts.filter((p: any) => p.type === "text")
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
if (textParts.length === 0) {
return null
}
return textParts.map((p: any) => p.text).join("\n")
return textParts.map((p) => p.text).join("\n")
}
async function handleRevert(messageId: string) {
@@ -555,9 +556,9 @@ const App: Component = () => {
modelID: session.model.modelId,
},
})
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to compact session:", error)
const message = error?.message || "Failed to compact session"
const message = error instanceof Error ? error.message : "Failed to compact session"
alert(`Compact failed: ${message}`)
}
},
@@ -630,11 +631,11 @@ const App: Component = () => {
// Restore the reverted user message to the prompt input
if (revertedMessage && revertedInfo?.role === "user") {
const textParts = revertedMessage.parts.filter((p: any) => p.type === "text")
const textParts = revertedMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
if (textParts.length > 0) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = textParts.map((p: any) => p.text).join("\n")
textarea.value = textParts.map((p) => p.text).join("\n")
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}

View File

@@ -1,5 +1,6 @@
import { createMemo, Show, onMount, createEffect } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setToolRenderCache } from "../lib/tool-render-cache"
@@ -34,11 +35,11 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
return {
oldFile: {
fileName,
fileLang: language as any, // Cast to any to avoid type issues with DiffHighlighterLang
fileLang: (language || "text") as DiffHighlighterLang | null,
},
newFile: {
fileName,
fileLang: language as any, // Cast to any to avoid type issues with DiffHighlighterLang
fileLang: (language || "text") as DiffHighlighterLang | null,
},
hunks: [normalized],
}

View File

@@ -1,5 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
interface FileItem {
path: string
added?: number
@@ -12,7 +14,7 @@ interface FilePickerProps {
onSelect: (path: string) => void
onNavigate: (direction: "up" | "down") => void
onClose: () => void
instanceClient: any
instanceClient: OpencodeClient
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceFolder: string

View File

@@ -1,5 +1,5 @@
import { Component, Show, For, onMount, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import type { Instance, RawMcpStatus } from "../types/instance"
interface InstanceInfoProps {
instance: Instance
@@ -12,12 +12,12 @@ type ParsedMcpStatus = {
error?: string
}
function parseMcpStatus(status: unknown): ParsedMcpStatus[] {
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status as Record<string, unknown>)) {
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
@@ -47,7 +47,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const metadata = () => props.instance.metadata
const mcpServers = () => {
const status = metadata()?.mcpStatus
return parseMcpStatus(status)
return status ? parseMcpStatus(status) : []
}
onMount(async () => {
@@ -64,7 +64,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
])
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data as RawMcpStatus : undefined
const { updateInstance } = await import("../stores/instances")
updateInstance(props.instance.id, {

View File

@@ -1,14 +1,13 @@
import { For, Show } from "solid-js"
import type { Message } from "../types/message"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
messageInfo?: any
messageInfo?: MessageInfo
isQueued?: boolean
parts?: any[]
parts?: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
}
@@ -23,9 +22,10 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts ?? props.message.parts
const errorMessage = () => {
if (!props.messageInfo?.error) return null
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.error) return null
const error = props.messageInfo.error
const error = info.error
if (error.name === "ProviderAuthError") {
return error.data?.message || "Authentication error"
}
@@ -50,7 +50,8 @@ export default function MessageItem(props: MessageItemProps) {
}
const isGenerating = () => {
return !hasContent() && props.messageInfo?.time?.completed === 0
const info = props.messageInfo
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
}
const handleRevert = () => {
@@ -66,29 +67,17 @@ export default function MessageItem(props: MessageItemProps) {
const agentIdentifier = () => {
if (isUser()) return ""
return (
props.messageInfo?.agent ||
props.messageInfo?.mode ||
props.messageInfo?.agentID ||
props.messageInfo?.agentId ||
props.messageInfo?.metadata?.agentID ||
""
)
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
if (isUser()) return ""
const modelID =
props.messageInfo?.modelID ||
props.messageInfo?.modelId ||
props.messageInfo?.model?.modelID ||
props.messageInfo?.model?.id ||
""
const providerID =
props.messageInfo?.providerID ||
props.messageInfo?.providerId ||
props.messageInfo?.model?.providerID ||
""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}

View File

@@ -4,10 +4,12 @@ import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { preferences } from "../stores/preferences"
import { partHasRenderableText } from "../types/message"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
interface MessagePartProps {
part: any
part: ClientPart
messageType?: "user" | "assistant"
}
export default function MessagePart(props: MessagePartProps) {
@@ -21,22 +23,30 @@ export default function MessagePart(props: MessagePartProps) {
const plainTextContent = () => {
const part = props.part
if (typeof part?.text === "string") {
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
return part.text
}
const segments: string[] = []
const contentArray = Array.isArray(part?.content) ? part.content : []
return ""
}
for (const item of contentArray) {
if (typeof item === "string") {
segments.push(item)
} else if (item && typeof item === "object" && typeof (item as { text?: unknown }).text === "string") {
segments.push((item as { text: string }).text)
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version
}
}
return segments.join("\n")
return {
id: part.id,
type: "text",
text: "",
synthetic: false
}
}
function handleReasoningClick(e: Event) {
@@ -47,25 +57,23 @@ export default function MessagePart(props: MessagePartProps) {
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!props.part.synthetic && partHasRenderableText(props.part)}>
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown part={props.part} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</Show>
</div>
</Show>
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part} toolCallId={props.part?.id} />
<ToolCall toolCall={props.part as ToolCallPart} toolCallId={props.part?.id} />
</Match>
<Match when={partType() === "error"}>
<div class="message-error-part"> {props.part.message}</div>
</Match>
<Match when={partType() === "reasoning"}>
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
@@ -77,7 +85,7 @@ export default function MessagePart(props: MessagePartProps) {
</div>
<Show when={isReasoningExpanded()}>
<div class={`${textContainerClass()} mt-2`}>
<Markdown part={props.part} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</div>
</Show>
</div>

View File

@@ -1,5 +1,31 @@
import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js"
import type { Message, MessageDisplayParts } from "../types/message"
import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
// Import ToolState types from SDK
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
// Type guards
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
return state.status === "running"
}
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
return state.status === "completed"
}
function isToolStateError(state: ToolState): state is ToolStateError {
return state.status === "error"
}
// Type guard to check if a part is a tool part
function isToolPart(part: ClientPart): part is ToolCallPart {
return part.type === "tool"
}
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import { sseManager } from "../lib/sse-manager"
@@ -51,25 +77,26 @@ function navigateToTaskSession(location: TaskSessionLocation) {
}
// Calculate session tokens and cost from messagesInfo (matches TUI logic)
function calculateSessionInfo(messagesInfo?: Map<string, any>, instanceId?: string) {
function calculateSessionInfo(messagesInfo?: Map<string, MessageInfo>, instanceId?: string) {
if (!messagesInfo || messagesInfo.size === 0)
return { tokens: 0, cost: 0, contextWindow: 0, isSubscriptionModel: false }
let tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let isSubscriptionModel = false
// Go backwards through messages to find the last relevant assistant message (like TUI)
const messageArray = Array.from(messagesInfo.values()).reverse()
for (const info of messageArray) {
if (!info) continue
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
if (usage.output > 0) {
if (usage.output && usage.output > 0) {
if (info.summary) {
// If summary message, only count output tokens and stop (like TUI)
tokens = usage.output || 0
@@ -145,7 +172,7 @@ interface MessageStreamProps {
instanceId: string
sessionId: string
messages: Message[]
messagesInfo?: Map<string, any>
messagesInfo?: Map<string, MessageInfo>
revert?: {
messageID: string
partID?: string
@@ -160,16 +187,16 @@ interface MessageStreamProps {
interface MessageDisplayItem {
type: "message"
message: Message
combinedParts: any[]
combinedParts: ClientPart[]
isQueued: boolean
messageInfo?: any
messageInfo?: MessageInfo
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: any
messageInfo?: any
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
@@ -178,22 +205,25 @@ interface ToolDisplayItem {
type DisplayItem = MessageDisplayItem | ToolDisplayItem
interface MessageCacheEntry {
message: Message
version: number
showThinking: boolean
isQueued: boolean
messageInfo?: any
messageInfo?: MessageInfo
displayParts: MessageDisplayParts
item: MessageDisplayItem
}
interface ToolCacheEntry {
toolPart: any
messageInfo?: any
toolPart: ClientPart
messageInfo?: MessageInfo
signature: string
contentKey: string
item: ToolDisplayItem
}
interface SessionCache {
messageItemCache: Map<string, MessageCacheEntry>
toolItemCache: Map<string, ToolCacheEntry>
@@ -231,18 +261,17 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string {
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
return `${messageId}:${partId}`
}
function createToolContentKey(toolPart: any, messageInfo?: any): string {
const state = toolPart?.state ?? {}
const version = typeof toolPart?.version === "number" ? toolPart.version : null
function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string {
const state = isToolPart(toolPart) ? toolPart.state : undefined
const version = typeof toolPart?.version === "number" ? toolPart.version : 0
const status = state?.status ?? "unknown"
return `${toolPart.id}:${version}:${status}`
}
const sessionInfo = createMemo(() => {
@@ -383,7 +412,7 @@ export default function MessageStream(props: MessageStreamProps) {
const hasRenderableContent =
message.type !== "assistant" ||
combinedParts.length > 0 ||
Boolean(messageInfo?.error) ||
Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) ||
message.status === "error"
if (hasRenderableContent) {
@@ -415,6 +444,7 @@ export default function MessageStream(props: MessageStreamProps) {
messageInfo,
}
newMessageCache.set(message.id, {
message,
version,
showThinking,
isQueued,
@@ -426,13 +456,15 @@ export default function MessageStream(props: MessageStreamProps) {
}
}
for (let toolIndex = 0; toolIndex < displayParts.tool.length; toolIndex++) {
const toolPart = displayParts.tool[toolIndex]
const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}`
const toolParts = displayParts.tool.filter(isToolPart)
for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) {
const toolPart = toolParts[toolIndex]
const originalIndex = displayParts.tool.indexOf(toolPart)
const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}`
const messageVersion = typeof message.version === "number" ? message.version : 0
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo)
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
const contentKey = createToolContentKey(toolPart, messageInfo)
const toolEntry = toolItemCache.get(toolKey)
if (toolEntry && toolEntry.signature === toolSignature) {
@@ -450,7 +482,7 @@ export default function MessageStream(props: MessageStreamProps) {
toolEntry.signature = toolSignature
toolEntry.contentKey = contentKey
toolEntry.item = updatedItem
console.debug("[ToolCall] update", toolKey, toolPart?.state?.status)
console.debug("[ToolCall] update", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, toolEntry)
items.push(updatedItem)
} else {
@@ -475,7 +507,7 @@ export default function MessageStream(props: MessageStreamProps) {
messageVersion,
partVersion,
}
console.debug("[ToolCall] create", toolKey, toolPart?.state?.status)
console.debug("[ToolCall] create", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
items.push(toolItem)
}
@@ -674,7 +706,9 @@ export default function MessageStream(props: MessageStreamProps) {
const toolPart = item.toolPart
const taskSessionId =
typeof toolPart?.state?.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : ""
(isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state))
? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : ""
: ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const handleGoToTaskSession = (event: Event) => {

View File

@@ -4,6 +4,7 @@ import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import HintRow from "./hint-row"
import { getActiveInstance } from "../stores/instances"
@@ -516,7 +517,7 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null)
}
function handlePickerSelect(item: { type: "agent"; agent: any } | { type: "file"; file: any }) {
function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) {
if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = attachments()
@@ -642,7 +643,7 @@ export default function PromptInput(props: PromptInputProps) {
for (let i = 0; i < files.length; i++) {
const file = files[i]
const path = (file as any).path || file.name
const path = (file as File & { path?: string }).path || file.name
const filename = file.name
const mime = file.type || "text/plain"

View File

@@ -7,20 +7,40 @@ import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences"
import type { TextPart } from "../types/message"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
// Import ToolState types from SDK
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
// Type guards
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
return state.status === "running"
}
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
return state.status === "completed"
}
function isToolStateError(state: ToolState): state is ToolStateError {
return state.status === "error"
}
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
function makeRenderCacheKey(
baseId?: string | null,
toolCallId?: string | null,
messageId?: string,
messageVersion?: number,
partVersion?: number,
) {
if (!baseId && !messageId) return undefined
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
const keyBase = baseId || messageId || "tool"
const keyBase = `${messageId}:${toolCallId}`
return `${keyBase}::${suffix}`
}
@@ -55,7 +75,7 @@ function restoreScrollState(id: string, element: HTMLElement) {
interface ToolCallProps {
toolCall: any
toolCall: Extract<ClientPart, { type: "tool" }>
toolCallId?: string
messageId?: string
messageVersion?: number
@@ -122,12 +142,17 @@ interface DiffPayload {
filePath?: string
}
function extractDiffPayload(toolName: string, state: any): DiffPayload | null {
function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | null {
if (!diffCapableTools.has(toolName)) return null
if (!state) return null
const metadata = state.metadata || {}
const candidates = [metadata.diff, state.output, metadata.output]
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const output = isToolStateCompleted(state) ? state.output : undefined
const candidates = [metadata.diff, output, metadata.output]
let diffText: string | null = null
for (const candidate of candidates) {
@@ -141,8 +166,12 @@ function extractDiffPayload(toolName: string, state: any): DiffPayload | null {
return null
}
const input = state.input || {}
const filePath = input.filePath || metadata.filePath || input.path
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.input as Record<string, unknown>
: {}
const filePath = (typeof input.filePath === "string" ? input.filePath : undefined) ||
(typeof metadata.filePath === "string" ? metadata.filePath : undefined) ||
(typeof input.path === "string" ? input.path : undefined)
return { diffText, filePath }
}
@@ -202,7 +231,12 @@ export default function ToolCall(props: ToolCallProps) {
createEffect(() => {
if (props.toolCall?.tool !== "task") return
const summarySignature = JSON.stringify(props.toolCall?.state?.metadata?.summary ?? [])
const state = props.toolCall?.state
const summarySignature = JSON.stringify(
state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata?.summary ?? []
: []
)
requestAnimationFrame(() => {
void summarySignature
handleScrollRendered()
@@ -288,14 +322,20 @@ export default function ToolCall(props: ToolCallProps) {
const renderToolTitle = () => {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {}
const input = state.input || {}
const state = props.toolCall?.state
if (state.status === "pending") {
return renderToolAction()
if (!state) return renderToolAction()
if (state.status === "pending") return renderToolAction()
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? (state.input as Record<string, unknown>)
: {} as Record<string, unknown>
if (isToolStateRunning(state) && state.title) {
return state.title
}
if (state.title) {
if (isToolStateCompleted(state)) {
return state.title
}
@@ -303,20 +343,20 @@ export default function ToolCall(props: ToolCallProps) {
switch (toolName) {
case "read":
if (input.filePath) {
if (typeof input.filePath === "string") {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "edit":
case "write":
if (input.filePath) {
if (typeof input.filePath === "string") {
return `${name} ${getRelativePath(input.filePath)}`
}
return name
case "bash":
if (input.description) {
if (typeof input.description === "string") {
return `${name} ${input.description}`
}
return name
@@ -344,7 +384,7 @@ export default function ToolCall(props: ToolCallProps) {
return "Plan"
case "invalid":
if (input.tool) {
if (typeof input.tool === "string") {
return getToolName(input.tool)
}
return name
@@ -454,7 +494,7 @@ export default function ToolCall(props: ToolCallProps) {
)
}
function renderMarkdownTool(toolName: string, state: any) {
function renderMarkdownTool(toolName: string, state: ToolState) {
const content = getMarkdownContent(toolName, state)
if (!content) {
return null
@@ -496,53 +536,69 @@ export default function ToolCall(props: ToolCallProps) {
)
}
function getMarkdownContent(toolName: string, state: any): string | null {
const input = state?.input || {}
const metadata = state?.metadata || {}
function getMarkdownContent(toolName: string, state: ToolState): string | null {
if (!state) return null
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.input as Record<string, unknown>
: {}
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
switch (toolName) {
case "read": {
const preview = typeof metadata.preview === "string" ? metadata.preview : null
const language = getLanguageFromPath(input.filePath || "")
const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "")
return ensureMarkdownContent(preview, language, true)
}
case "edit": {
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = typeof state.output === "string" ? state.output : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
return ensureMarkdownContent(diffText || fallback, "diff", true)
}
case "write": {
const content = typeof input.content === "string" ? input.content : null
const metadataContent = typeof metadata.content === "string" ? metadata.content : null
const language = getLanguageFromPath(input.filePath || "")
const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "")
return ensureMarkdownContent(content || metadataContent, language, true)
}
case "patch": {
const patchContent = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = typeof state.output === "string" ? state.output : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
return ensureMarkdownContent(patchContent || fallback, "diff", true)
}
case "bash": {
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(metadata.output ?? state.output)
const outputResult = formatUnknown(
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
undefined
)
const parts = [command, outputResult?.text].filter(Boolean)
const combined = parts.join("\n")
return ensureMarkdownContent(combined, "bash", true)
}
case "webfetch": {
const result = formatUnknown(state.output ?? metadata.output)
const result = formatUnknown(
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
undefined
)
if (!result) return null
return ensureMarkdownContent(result.text, result.language, true)
}
default: {
const result = formatUnknown(
state.output ?? metadata.output ?? metadata.diff ?? metadata.preview ?? input.content,
isToolStateCompleted(state) ? state.output :
(isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output :
metadata.diff ?? metadata.preview ?? input.content,
)
if (!result) return null
return ensureMarkdownContent(result.text, result.language, true)
@@ -618,8 +674,12 @@ export default function ToolCall(props: ToolCallProps) {
}
const renderTodowriteTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const state = props.toolCall?.state
if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) {
@@ -677,8 +737,12 @@ export default function ToolCall(props: ToolCallProps) {
}
const renderTaskTool = () => {
const state = props.toolCall?.state || {}
const metadata = state.metadata || {}
const state = props.toolCall?.state
if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const summary = metadata.summary || []
if (!Array.isArray(summary) || summary.length === 0) {

View File

@@ -1,5 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
interface FileItem {
path: string
@@ -15,7 +16,7 @@ interface UnifiedPickerProps {
onSelect: (item: PickerItem) => void
onClose: () => void
agents: Agent[]
instanceClient: any
instanceClient: OpencodeClient | null
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceFolder: string

View File

@@ -1,4 +1,16 @@
import { createSignal } from "solid-js"
import {
MessageUpdateEvent,
MessageRemovedEvent,
MessagePartUpdatedEvent,
MessagePartRemovedEvent
} from "../types/message"
import type {
EventSessionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle
} from "@opencode-ai/sdk"
interface SSEConnection {
instanceId: string
@@ -7,19 +19,6 @@ interface SSEConnection {
status: "connecting" | "connected" | "disconnected" | "error"
}
interface MessageUpdateEvent {
type: "message_updated"
sessionId: string
messageId: string
parts: any[]
status: string
}
interface SessionUpdateEvent {
type: "session_updated"
session: any
}
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
@@ -30,12 +29,17 @@ interface TuiToastEvent {
}
}
interface SessionIdleEvent {
type: "session.idle"
properties: {
sessionID: string
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
| MessagePartUpdatedEvent
| MessagePartRemovedEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionError
| EventSessionIdle
| TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
const [connectionStatus, setConnectionStatus] = createSignal<
Map<string, "connecting" | "connected" | "disconnected" | "error">
@@ -98,34 +102,36 @@ class SSEManager {
}
}
private handleEvent(instanceId: string, event: any): void {
private handleEvent(instanceId: string, event: SSEEvent): void {
console.log("[SSE] Received event:", event.type, event)
switch (event.type) {
case "message.updated":
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
break
case "message.part.updated":
this.onMessageUpdate?.(instanceId, event)
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
break
case "message.removed":
this.onMessageRemoved?.(instanceId, event)
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
break
case "message.part.removed":
this.onMessagePartRemoved?.(instanceId, event)
this.onMessagePartRemoved?.(instanceId, event as MessagePartRemovedEvent)
break
case "session.updated":
this.onSessionUpdate?.(instanceId, event)
this.onSessionUpdate?.(instanceId, event as EventSessionUpdated)
break
case "session.compacted":
this.onSessionCompacted?.(instanceId, event)
this.onSessionCompacted?.(instanceId, event as EventSessionCompacted)
break
case "session.error":
this.onSessionError?.(instanceId, event)
this.onSessionError?.(instanceId, event as EventSessionError)
break
case "tui.toast.show":
this.onTuiToast?.(instanceId, event as TuiToastEvent)
break
case "session.idle":
this.onSessionIdle?.(instanceId, event as SessionIdleEvent)
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
@@ -162,13 +168,14 @@ class SSEManager {
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onMessageRemoved?: (instanceId: string, event: any) => void
onMessagePartRemoved?: (instanceId: string, event: any) => void
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
onSessionCompacted?: (instanceId: string, event: any) => void
onSessionError?: (instanceId: string, event: any) => void
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: SessionIdleEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
return connectionStatus().get(instanceId) ?? null

View File

@@ -4,6 +4,7 @@ export interface ConfigData {
preferences: Preferences
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
theme?: "light" | "dark" | "system"
}
export interface InstanceData {

View File

@@ -1,5 +1,5 @@
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js"
import { storage } from "./storage"
import { storage, type ConfigData } from "./storage"
interface ThemeContextValue {
isDark: () => boolean
@@ -28,7 +28,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
async function loadTheme() {
try {
const config = await storage.loadConfig()
const savedTheme = (config as any)?.theme
const savedTheme = config.theme
let themeDark: boolean
if (savedTheme === "system") {
@@ -60,7 +60,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
try {
const config = await storage.loadConfig()
const nextPreference = dark ? "dark" : "light"
;(config as any).theme = nextPreference
config.theme = nextPreference
themePreference = nextPreference
await storage.saveConfig(config)
} catch (error) {

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Message, MessageDisplayParts } from "../types/message"
import type { Message, MessageDisplayParts, MessagePartRemovedEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { instances } from "./instances"
@@ -8,6 +8,22 @@ import { sseManager } from "../lib/sse-manager"
import { decodeHtmlEntities } from "../lib/markdown"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences"
import type {
EventSessionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle
} from "@opencode-ai/sdk"
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
interface SessionInfo {
tokens: number
@@ -275,13 +291,17 @@ export function computeDisplayParts(message: Message, showThinking: boolean): Me
function initializePartVersion(part: any, version = 0) {
if (!part || typeof part !== "object") return
const partAny = part as any
partAny.version = typeof partAny.version === "number" ? partAny.version : version
// Ensure version is always set for client-side part tracking
if (typeof partAny.version !== "number") {
partAny.version = version
}
}
function bumpPartVersion(previousPart: any, nextPart: any): number {
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
const nextVersion = prevVersion + 1
initializePartVersion(nextPart, nextVersion)
// Always initialize the version on the new part
nextPart.version = nextVersion
return nextVersion
}
@@ -383,6 +403,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
parentId: apiSession.parentID || null,
agent: "",
model: { providerId: "", modelId: "" },
version: apiSession.version, // Include version from SDK
time: {
created: apiSession.time.created,
updated: apiSession.time.updated,
@@ -639,6 +660,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
parentId: null,
agent: selectedAgent,
model: defaultModel,
version: response.data.version, // Include version from SDK
time: {
created: response.data.time.created,
updated: response.data.time.updated,
@@ -743,6 +765,7 @@ async function forkSession(
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
version: "0", // Default version for forked sessions
time: {
created: info.time?.created || Date.now(),
updated: info.time?.updated || Date.now(),
@@ -1157,7 +1180,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
updateSessionInfo(instanceId, sessionId)
}
function handleMessageUpdate(instanceId: string, event: any): void {
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
@@ -1330,7 +1353,165 @@ function handleMessageUpdate(instanceId: string, event: any): void {
}
withSession(instanceId, part.sessionID, (session) => {
// Session already mutated in place
// All the message mutation logic should go here to ensure reactivity
const index = getSessionIndex(instanceId, part.sessionID)
let messageIndex = index.messageIndex.get(part.messageID)
let replacedTemp = false
if (messageIndex === undefined) {
// Search for queued message with status 'sending' and no server id
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === part.sessionID && msg.status === "sending") {
messageIndex = i
replacedTemp = true
break
}
}
}
if (messageIndex === undefined) {
// Create new message
const newMessage: Message = {
id: part.messageID,
sessionId: part.sessionID,
type: "assistant" as const,
parts: [part],
timestamp: Date.now(),
status: "streaming" as const,
version: 0,
}
initializePartVersion(part)
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
} else {
// Update existing message
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
// Strip synthetic parts when real data arrives
let filteredSynthetics = false
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
filteredSynthetics = true
// Clear render cache from remaining parts when synthetic parts are removed
message.parts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
}
let baseParts: any[]
if (replacedTemp) {
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
message.parts = baseParts
// Clear render cache when replacing temp content
baseParts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
} else {
baseParts = message.parts
}
// Update part in place
let partMap = index.partIndex.get(message.id)
if (!partMap) {
partMap = new Map()
index.partIndex.set(message.id, partMap)
}
let shouldIncrementVersion = filteredSynthetics || replacedTemp
const partIndex = partMap.get(part.id)
if (partIndex === undefined) {
initializePartVersion(part)
baseParts.push(part)
if (part.id && typeof part.id === "string") {
partMap.set(part.id, baseParts.length - 1)
}
shouldIncrementVersion = true
// Clear render cache for new text parts
if (part.type === "text") {
part.renderCache = undefined
}
} else {
const previousPart = baseParts[partIndex]
const textUnchanged =
!filteredSynthetics &&
!replacedTemp &&
part.type === "text" &&
previousPart?.type === "text" &&
previousPart.text === part.text
if (textUnchanged) {
return
}
bumpPartVersion(previousPart, part)
baseParts[partIndex] = part
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
shouldIncrementVersion = true
// Clear render cache when text changes
if (part.type === "text") {
part.renderCache = undefined
}
}
}
const oldId = message.id
message.id = replacedTemp ? part.messageID : message.id
message.status = message.status === "sending" ? "streaming" : message.status
message.parts = baseParts
if (shouldIncrementVersion) {
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
} else if (
!message.displayParts ||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
message.displayParts.version !== message.version
) {
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
// Update message index if ID changed
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, messageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
// Refresh part indexes after filtering synthetic parts or replacing optimistic content
if (filteredSynthetics || replacedTemp) {
const partMap = new Map<string, number>()
message.parts.forEach((partItem, idx) => {
if (partItem.id && typeof partItem.id === "string") {
partMap.set(partItem.id, idx)
}
})
index.partIndex.set(message.id, partMap)
}
}
})
updateSessionInfo(instanceId, part.sessionID)
@@ -1432,14 +1613,102 @@ function handleMessageUpdate(instanceId: string, event: any): void {
session.messagesInfo.set(info.id, info)
withSession(instanceId, info.sessionID, (session) => {
// Session already mutated in place
const index = getSessionIndex(instanceId, info.sessionID)
let messageIndex = index.messageIndex.get(info.id)
if (messageIndex === undefined) {
// Look for queued message to replace
let tempMessageIndex = -1
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (
msg.sessionId === info.sessionID &&
msg.type === (info.role === "user" ? "user" : "assistant") &&
msg.status === "sending"
) {
tempMessageIndex = i
break
}
}
if (tempMessageIndex === -1) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === info.sessionID && msg.status === "sending") {
tempMessageIndex = i
break
}
}
}
if (tempMessageIndex > -1) {
// Replace queued message
const message = session.messages[tempMessageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
const oldId = message.id
message.id = info.id
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
message.timestamp = info.time?.created || Date.now()
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, tempMessageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
} else {
// Append new message
const newMessage: Message = {
id: info.id,
sessionId: info.sessionID,
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
parts: [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
}
} else {
// Update existing message status
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
session.messagesInfo.set(info.id, info)
})
updateSessionInfo(instanceId, info.sessionID)
}
}
function handleSessionUpdate(instanceId: string, event: any): void {
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
if (!info) return
@@ -1455,11 +1724,12 @@ function handleSessionUpdate(instanceId: string, event: any): void {
instanceId,
title: info.title || "Untitled",
parentId: info.parentID || null,
agent: info.agent || "",
agent: "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
providerId: "",
modelId: "",
},
version: info.version || "0",
time: {
created: info.time?.created || Date.now(),
updated: info.time?.updated || Date.now(),
@@ -1481,13 +1751,6 @@ function handleSessionUpdate(instanceId: string, event: any): void {
const updatedSession = {
...existingSession,
title: info.title || existingSession.title,
agent: info.agent || existingSession.agent,
model: info.model
? {
providerId: info.model.providerID || existingSession.model.providerId,
modelId: info.model.modelID || existingSession.model.modelId,
}
: existingSession.model,
time: {
...existingSession.time,
updated: info.time?.updated || Date.now(),
@@ -1499,7 +1762,7 @@ function handleSessionUpdate(instanceId: string, event: any): void {
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
: existingSession.revert,
}
setSessions((prev) => {
@@ -1512,6 +1775,15 @@ function handleSessionUpdate(instanceId: string, event: any): void {
}
}
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
console.log(`[SSE] Session idle: ${sessionId}`)
// Could be used to show user that the session is idle/finished processing
// For now, just log it - could be extended to show a notification or update UI state
}
function resolvePastedPlaceholders(prompt: string, attachments: any[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
return prompt
@@ -1779,7 +2051,7 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
function handleSessionCompacted(instanceId: string, event: any): void {
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
@@ -1800,26 +2072,25 @@ function handleSessionCompacted(instanceId: string, event: any): void {
})
}
function handleSessionError(instanceId: string, event: any): void {
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
const sessionID = event.properties?.sessionID
console.error(`[SSE] Session error:`, error)
let message = error?.data?.message || error?.message || "Unknown error"
let message = "Unknown error"
if (error?.data?.responseBody) {
try {
const body = JSON.parse(error.data.responseBody)
if (body.error) {
message = body.error
}
} catch {}
if (error) {
if ('data' in error && error.data && typeof error.data === 'object' && 'message' in error.data) {
message = error.data.message as string
} else if ('message' in error && typeof error.message === 'string') {
message = error.message
}
}
alert(`Error: ${message}`)
}
function handleMessageRemoved(instanceId: string, event: any): void {
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
@@ -1827,7 +2098,7 @@ function handleMessageRemoved(instanceId: string, event: any): void {
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleMessagePartRemoved(instanceId: string, event: any): void {
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
@@ -1835,7 +2106,7 @@ function handleMessagePartRemoved(instanceId: string, event: any): void {
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleTuiToast(_instanceId: string, event: any): void {
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
const payload = event?.properties
if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return
if (!payload.message.trim()) return
@@ -1853,11 +2124,13 @@ function handleTuiToast(_instanceId: string, event: any): void {
}
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onMessagePartUpdated = handleMessageUpdate
sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle
sseManager.onTuiToast = handleTuiToast
export {

View File

@@ -1,4 +1,5 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import type { Project as SDKProject } from "@opencode-ai/sdk"
export interface LogEntry {
timestamp: number
@@ -6,24 +7,23 @@ export interface LogEntry {
message: string
}
export interface ProjectInfo {
id: string
worktree: string
vcs?: "git"
time: {
created: number
initialized?: number
}
}
// Use SDK Project type instead of our own
export type ProjectInfo = SDKProject
export interface McpServerStatus {
name: string
status: "running" | "stopped" | "error"
}
// Raw MCP status from server (SDK returns unknown for /mcp endpoint)
export type RawMcpStatus = Record<string, {
status?: string
error?: string
}>
export interface InstanceMetadata {
project?: ProjectInfo
mcpStatus?: unknown
mcpStatus?: RawMcpStatus
version?: string
}

View File

@@ -1,3 +1,23 @@
// SDK types
import type {
EventMessageUpdated as MessageUpdateEvent,
EventMessageRemoved as MessageRemovedEvent,
EventMessagePartUpdated as MessagePartUpdatedEvent,
EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart,
Message as SDKMessage
} from "@opencode-ai/sdk"
// Re-export for other modules
export type {
MessageUpdateEvent,
MessageRemovedEvent,
MessagePartUpdatedEvent,
MessagePartRemovedEvent,
SDKPart,
SDKMessage
}
export interface RenderCache {
text: string
html: string
@@ -5,11 +25,20 @@ export interface RenderCache {
mode?: string
}
// Client-specific part extensions (using intersection type since SDKPart is a union)
export type ClientPart = SDKPart & {
sessionID?: string
messageID?: string
synthetic?: boolean
version?: number
renderCache?: RenderCache
}
export interface MessageDisplayParts {
text: any[]
tool: any[]
reasoning: any[]
combined: any[]
text: ClientPart[]
tool: ClientPart[]
reasoning: ClientPart[]
combined: ClientPart[]
showThinking: boolean
version: number
}
@@ -18,7 +47,7 @@ export interface Message {
id: string
sessionId: string
type: "user" | "assistant"
parts: any[]
parts: ClientPart[]
timestamp: number
status: "sending" | "sent" | "streaming" | "complete" | "error"
version: number
@@ -34,42 +63,41 @@ export interface TextPart {
renderCache?: RenderCache
}
function hasTextSegment(segment: unknown): boolean {
export type MessageInfo = SDKMessage
function hasTextSegment(segment: string | { text?: string }): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const maybeText = (segment as { text?: unknown }).text
if (typeof maybeText === "string") {
return maybeText.trim().length > 0
}
if (segment && typeof segment === "object" && segment.text) {
return typeof segment.text === "string" && segment.text.trim().length > 0
}
return false
}
export function partHasRenderableText(part: any): boolean {
export function partHasRenderableText(part: ClientPart): boolean {
if (!part || typeof part !== "object") {
return false
}
if (hasTextSegment(part.text)) {
const typedPart = part as SDKPart
if (typedPart.type === "text" && hasTextSegment(typedPart.text)) {
return true
}
const contentArray = Array.isArray(part?.content) ? part.content : []
for (const item of contentArray) {
if (hasTextSegment(item)) {
return true
}
if (typedPart.type === "file" && (typedPart as any).filename) {
return true
}
const thinkingContent = Array.isArray(part?.thinking?.content) ? part.thinking.content : []
for (const chunk of thinkingContent) {
if (hasTextSegment(chunk)) {
return true
}
if (typedPart.type === "tool") {
return true // Tool parts are always renderable
}
if (typedPart.type === "reasoning" && hasTextSegment(typedPart.text)) {
return true
}
return false

View File

@@ -1,29 +1,64 @@
import type { Message } from "./message"
import type { Message, MessageInfo } from "./message"
import type {
Session as SDKSession,
Agent as SDKAgent,
Provider as SDKProvider,
Model as SDKModel
} from "@opencode-ai/sdk"
export interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
// Export SDK types for external use
export type {
Session as SDKSession,
Agent as SDKAgent,
Provider as SDKProvider,
Model as SDKModel
} from "@opencode-ai/sdk"
// Our client-specific Session interface extending SDK Session
export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'projectID' | 'directory' | 'parentID'> {
instanceId: string // Client-specific field
parentId: string | null // Client-specific field (override parentID)
agent: string // Client-specific field
model: { // Client-specific field
providerId: string
modelId: string
}
time: {
created: number
updated: number
}
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
messages: Message[]
messagesInfo: Map<string, any>
messages: Message[] // Client-specific field
messagesInfo: Map<string, MessageInfo> // Client-specific field
version: string // Include version from SDK Session
}
// Adapter function to convert SDK Session to client Session
export function createClientSession(
sdkSession: import("@opencode-ai/sdk").Session,
instanceId: string,
agent: string = "",
model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }
): Session {
return {
...sdkSession,
instanceId,
parentId: sdkSession.parentID || null,
agent,
model,
messages: [],
messagesInfo: new Map(),
}
}
// Type guard to check if object is SDK Session
export function isSdkSession(obj: unknown): obj is import("@opencode-ai/sdk").Session {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"title" in obj &&
"version" in obj &&
"time" in obj
)
}
// Our client-specific Agent interface (simplified version of SDK Agent)
export interface Agent {
name: string
description: string
@@ -34,6 +69,7 @@ export interface Agent {
}
}
// Our client-specific Provider interface (simplified version of SDK Provider)
export interface Provider {
id: string
name: string
@@ -41,6 +77,7 @@ export interface Provider {
defaultModelId?: string
}
// Our client-specific Model interface (simplified version of SDK Model)
export interface Model {
id: string
name: string