Initial commit: OpenCode mobile app (extracted from opencode monorepo)

This commit is contained in:
Ubuntu
2026-05-17 19:52:54 +00:00
commit 1843a7c37e
84 changed files with 9303 additions and 0 deletions

434
src/lib/sdk.ts Normal file
View File

@@ -0,0 +1,434 @@
// SDK client wrapper for React Native
// We create our own lightweight client that mirrors the opencode SDK patterns
// but works in React Native environment
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
import { fetch as expoFetch } from "expo/fetch"
export interface ClientConfig {
baseUrl: string
directory?: string
auth?: {
username: string
password: string
}
}
export interface Session {
id: string
slug: string
projectID: string
directory: string
parentID?: string
title: string
version: string
share?: { url: string }
time: {
created: number
updated: number
compacting?: number
archived?: number
}
summary?: {
additions: number
deletions: number
files: number
}
}
export interface Message {
id: string
sessionID: string
role: "user" | "assistant"
parentID?: string
time: {
created: number
completed?: number
}
// User message fields
agent?: string
model?: { providerID: string; modelID: string }
// Assistant message fields
modelID?: string
providerID?: string
cost?: number
tokens?: {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}
error?: { message: string }
finish?: string
}
// API returns messages with parts embedded
export interface MessageWithParts {
info: Message
parts: Part[]
}
export interface Part {
id: string
sessionID?: string
messageID: string
type:
| "text"
| "reasoning"
| "tool"
| "file"
| "snapshot"
| "patch"
| "step-start"
| "step-finish"
| "subtask"
| "retry"
| "compaction"
| "agent"
// Text / reasoning part
text?: string
// Tool part
tool?: string
callID?: string
state?: {
status: "pending" | "running" | "completed" | "error"
input?: unknown
output?: unknown
title?: string
error?: { message: string }
time?: { start?: number; end?: number }
}
// Timing
time?: { start?: number; end?: number }
// File part
mime?: string
url?: string
filename?: string
}
export interface Agent {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
model?: { modelID: string; providerID: string }
prompt?: string
options: Record<string, unknown>
steps?: number
}
export interface Command {
name: string
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: string[]
}
export interface Project {
id: string
name?: string
path: {
cwd: string
root: string
absolute: string
}
}
export interface Event {
type: string
properties: Record<string, unknown>
}
export interface HealthResponse {
healthy: boolean
version: string
}
const REQUEST_TIMEOUT_MS = 30_000
function createHeaders(config: ClientConfig): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
}
if (config.directory) {
const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory
headers["x-opencode-directory"] = encoded
}
if (config.auth) {
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
headers["Authorization"] = `Basic ${credentials}`
}
return headers
}
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
const url = `${config.baseUrl}${path}`
const headers = { ...createHeaders(config), ...options.headers }
const response = await fetchWithTimeout(url, {
...options,
headers,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`API Error: ${response.status} - ${error}`)
}
return response.json()
}
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
const parentSignal = options.signal
if (parentSignal?.aborted) throw new Error("Request aborted")
const controller = new AbortController()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
controller.abort()
}, REQUEST_TIMEOUT_MS)
const onParentAbort = () => controller.abort()
parentSignal?.addEventListener("abort", onParentAbort)
try {
return await fetch(url, { ...options, signal: controller.signal })
} catch (error) {
if (timedOut) {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
}
throw error
} finally {
clearTimeout(timeout)
parentSignal?.removeEventListener("abort", onParentAbort)
}
}
export function createClient(config: ClientConfig) {
return {
global: {
health: () => request<HealthResponse>(config, "/global/health"),
// SSE event stream - returns async iterator
// Pass an AbortSignal to cancel the connection
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
const url = `${config.baseUrl}/global/event`
const headers = createHeaders(config)
// Remove Content-Type for SSE (it's text/event-stream)
delete (headers as Record<string, string>)["Content-Type"]
// Must use expo/fetch for ReadableStream support on native
const response = await expoFetch(url, { headers, signal })
if (!response.ok || !response.body) {
throw new Error(`Failed to connect to event stream: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6)
if (data && data !== "[DONE]") {
try {
yield JSON.parse(data)
} catch (err) {
console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err)
}
}
}
}
}
} finally {
reader.releaseLock()
}
},
},
project: {
list: () => request<Project[]>(config, "/project"),
current: () => request<Project>(config, "/project/current"),
},
path: {
get: () =>
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
},
session: {
list: (params?: { roots?: boolean; limit?: number; search?: string }) => {
const query = new URLSearchParams()
if (params?.roots) query.set("roots", "true")
if (params?.limit) query.set("limit", String(params.limit))
if (params?.search) query.set("search", params.search)
const qs = query.toString()
return request<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
},
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),
create: (params?: { title?: string }) =>
request<Session>(config, "/session", {
method: "POST",
body: JSON.stringify(params || {}),
}),
delete: (sessionID: string) => request<void>(config, `/session/${sessionID}`, { method: "DELETE" }),
update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
request<Session>(config, `/session/${sessionID}`, {
method: "PATCH",
body: JSON.stringify(params),
}),
messages: (sessionID: string, params?: { limit?: number }) => {
const query = new URLSearchParams()
if (params?.limit) query.set("limit", String(params.limit))
const qs = query.toString()
return request<MessageWithParts[]>(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
},
// Sends a message and returns the response
// Fire-and-forget async prompt - SSE events drive all real-time updates
prompt: async (
sessionID: string,
params: {
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }>
model?: { providerID: string; modelID: string }
agent?: string
variant?: string
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
const headers = createHeaders(config)
const body = JSON.stringify(params)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to send message: ${response.status} - ${error}`)
}
},
command: async (
sessionID: string,
params: {
command: string
arguments: string
agent?: string
model?: string
variant?: string
parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/command`
const headers = createHeaders(config)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body: JSON.stringify({ ...params, sessionID }),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to run command: ${response.status} - ${error}`)
}
},
abort: (sessionID: string) => request<boolean>(config, `/session/${sessionID}/abort`, { method: "POST" }),
diff: (sessionID: string, messageID?: string) => {
const qs = messageID ? `?messageID=${messageID}` : ""
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
},
},
permission: {
list: () =>
request<Array<{ id: string; sessionID: string; tool: string; input: unknown }>>(config, "/permission"),
reply: (requestID: string, reply: "once" | "always" | "reject") =>
request<boolean>(config, `/permission/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ reply }),
}),
},
question: {
list: () => request<Array<{ id: string; sessionID: string; questions: unknown[] }>>(config, "/question"),
reply: (requestID: string, answers: string[][]) =>
request<boolean>(config, `/question/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ answers }),
}),
reject: (requestID: string) =>
request<boolean>(config, `/question/${requestID}/reject`, {
method: "POST",
}),
},
agent: {
list: () => request<Agent[]>(config, "/agent"),
},
command: {
list: () => request<Command[]>(config, "/command"),
},
provider: {
list: () =>
request<{
all: Array<{
id: string
name: string
models: Record<
string,
{
id: string
name: string
attachment: boolean
reasoning: boolean
tool_call: boolean
cost?: { input: number; output: number }
limit: { context: number; output: number }
status?: "alpha" | "beta" | "deprecated" | "active"
}
>
}>
default: Record<string, string>
connected: string[]
}>(config, "/provider"),
},
config: {
get: () => request<unknown>(config, "/config"),
},
}
}
export type Client = ReturnType<typeof createClient>