diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 163a5820d99..31273c66d20 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -26,6 +26,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { loadTheme } from "../theme-loader" type ToolProps = { input: Tool.InferParameters @@ -389,6 +390,15 @@ export const RunCommand = cmd({ } async function execute(sdk: OpencodeClient) { + let theme + try { + const configResult = await sdk.config.get() + const themeName = configResult.data?.theme + theme = loadTheme(themeName) + } catch { + theme = loadTheme() + } + function tool(part: ToolPart) { if (part.tool === "bash") return bash(props(part)) if (part.tool === "glob") return glob(props(part)) @@ -470,7 +480,7 @@ export const RunCommand = cmd({ continue } UI.empty() - UI.println(text) + UI.println(UI.markdown(text, theme)) UI.empty() } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 77872eedadd..0ffe47c6028 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -13,6 +13,7 @@ import { } from "solid-js" import { Dynamic } from "solid-js/web" import path from "path" + import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" @@ -26,7 +27,9 @@ import { type ScrollAcceleration, TextAttributes, RGBA, + StyledText, } from "@opentui/core" +import { Index } from "solid-js" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" @@ -77,6 +80,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { renderMarkdownThemedStyled, parseMarkdownSegments } from "@/cli/markdown-renderer" import { UI } from "@/cli/ui.ts" addDefaultParsers(parsers.parsers) @@ -1354,38 +1358,116 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass ) } +// ============================================================================ +// Markdown Rendering Components +// ============================================================================ + +const LANGS: Record = { + js: "javascript", + ts: "typescript", + jsx: "typescript", + tsx: "typescript", + py: "python", + rb: "ruby", + sh: "shell", + bash: "shell", + zsh: "shell", + yml: "yaml", + md: "markdown", +} + function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() - const { theme, syntax } = useTheme() + const tui = useTheme() + + // Parse markdown into segments - use Index to prevent recreation + const segments = createMemo(() => parseMarkdownSegments(props.part.text?.trim() ?? "")) + return ( - - - - - - - - - - + + + + {(segment) => ( + } + > + + + )} + ) } +// Render text segments with custom renderer (tables, inline formatting) +function Prose(props: { segment: { type: "text"; content: string }; theme: any; width: number }) { + let el: any + const styled = createMemo(() => { + if (!props.segment.content) return new StyledText([]) + const result = renderMarkdownThemedStyled(props.segment.content, props.theme, { cols: props.width }) + return new StyledText( + result.chunks.map((c) => ({ + __isChunk: true as const, + text: c.text, + fg: c.fg ? RGBA.fromInts(c.fg.r, c.fg.g, c.fg.b, c.fg.a) : props.theme.text, + bg: c.bg ? RGBA.fromInts(c.bg.r, c.bg.g, c.bg.b, c.bg.a) : undefined, + attributes: c.attributes, + })), + ) + }) + createEffect(() => { + if (el) el.content = styled() + }) + return +} + +// Render code blocks with tree-sitter highlighting +function CodeBlock(props: { segment: { type: "code"; content: string; language: string }; syntax: any }) { + const ctx = use() + const lang = () => LANGS[props.segment.language] || props.segment.language + + return ( + + + + ) +} + +// Prose and Diff components kept for potential future use with stable rendering +function Diff(props: { content: string; theme: ReturnType["theme"] }) { + let el: any + const styled = createMemo(() => { + const chunks = props.content.split("\n").map((line) => { + const t = line.trim() + const fg = t.startsWith("+") + ? props.theme.diffAdded + : t.startsWith("-") + ? props.theme.diffRemoved + : props.theme.markdownCodeBlock + return { __isChunk: true as const, text: " " + line + "\n", fg } + }) + return new StyledText(chunks) + }) + createEffect(() => { + if (el) el.content = styled() + }) + // Don't pass fg prop - chunks already have colors + return ( + + + + ) +} + // Pending messages moved to individual tool pending functions function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { diff --git a/packages/opencode/src/cli/markdown-renderer.ts b/packages/opencode/src/cli/markdown-renderer.ts new file mode 100644 index 00000000000..bc7e126f138 --- /dev/null +++ b/packages/opencode/src/cli/markdown-renderer.ts @@ -0,0 +1,1310 @@ +/** + * Terminal Markdown Renderer + * + * Inspired by Python's mdv (terminal_markdown_viewer) + * Transforms markdown into beautifully formatted terminal output + * with box-drawing characters, ANSI colors, and proper layout. + * + * This is a lightweight implementation that doesn't require external + * markdown parsing libraries - it uses regex-based parsing for common + * markdown elements. + */ + +const Box = { + topLeft: "\u250c", + topRight: "\u2510", + bottomLeft: "\u2514", + bottomRight: "\u2518", + horizontal: "\u2500", + vertical: "\u2502", + leftT: "\u251c", + rightT: "\u2524", + topT: "\u252c", + bottomT: "\u2534", + cross: "\u253c", + dblHorizontal: "\u2550", + dblVertical: "\u2551", + dblTopLeft: "\u2554", + dblTopRight: "\u2557", + dblBottomLeft: "\u255a", + dblBottomRight: "\u255d", + topLeftMixed: "\u2552", + topRightMixed: "\u2555", + bottomLeftMixed: "\u2558", + bottomRightMixed: "\u255b", + leftTMixed: "\u255e", + rightTMixed: "\u2561", + topTMixed: "\u2564", + bottomTMixed: "\u2567", + crossMixed: "\u256a", +} as const + +const Ansi = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + italic: "\x1b[3m", + underline: "\x1b[4m", + fg: { + black: "\x1b[30m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", + brightRed: "\x1b[91m", + brightGreen: "\x1b[92m", + brightYellow: "\x1b[93m", + brightBlue: "\x1b[94m", + brightMagenta: "\x1b[95m", + brightCyan: "\x1b[96m", + brightWhite: "\x1b[97m", + }, + fg256: (n: number) => `\x1b[38;5;${n}m`, + bg256: (n: number) => `\x1b[48;5;${n}m`, +} as const + +const Theme = { + heading: Ansi.bold, + headingBar: Ansi.fg.gray, + headingBg: Ansi.bg256(236), + text: Ansi.reset, + code: Ansi.fg.green, + codeBlock: Ansi.reset, + link: Ansi.fg.blue + Ansi.underline, + linkText: Ansi.fg.cyan, + bold: Ansi.bold, + italic: Ansi.fg.yellow + Ansi.italic, + dim: Ansi.dim, + listBullet: Ansi.fg.blue, + listNumber: Ansi.fg.cyan, + blockquote: Ansi.fg.yellow + Ansi.italic, + hr: Ansi.dim, + tableHeader: Ansi.fg.cyan, + tableBorder: Ansi.dim, + tableCell: Ansi.reset, + diffAdded: Ansi.fg.green, + diffRemoved: Ansi.fg.red, +} as const + +export interface TerminalMarkdownOptions { + cols?: number + indent?: string + codePrefix?: string + listPrefix?: string + colors?: boolean + theme?: MarkdownTheme +} + +/** + * Strip ANSI escape codes from a string + */ +function stripAnsi(str: string): string { + return str.replace(/\x1b\[[0-9;]*m/g, "") +} + +/** + * Get visible width of string (excluding ANSI codes) + */ +function visibleWidth(str: string): number { + return stripAnsi(str).length +} + +/** + * Pad string to width, accounting for ANSI codes + */ +function padRight(str: string, width: number, char = " "): string { + const visible = visibleWidth(str) + return visible >= width ? str : str + char.repeat(width - visible) +} + +function padCenter(str: string, width: number, char = " "): string { + const visible = visibleWidth(str) + if (visible >= width) return str + const left = Math.floor((width - visible) / 2) + const right = width - visible - left + return char.repeat(left) + str + char.repeat(right) +} + +/** + * Word wrap text to specified width + */ +function wordWrap(text: string, width: number, indent = ""): string { + const words = text.split(/\s+/) + const lines: string[] = [] + let line = indent + + for (const word of words) { + const testLine = line + (line === indent ? "" : " ") + word + if (visibleWidth(testLine) > width && line !== indent) { + lines.push(line) + line = indent + word + } else { + line = testLine + } + } + + if (line !== indent) lines.push(line) + return lines.join("\n") +} + +// ============================================================================ +// LEGACY FUNCTIONS - Used only by transformTables() export +// Main rendering now uses renderMarkdownThemedStyled() + textChunksToAnsi() +// ============================================================================ + +/** + * Format a table with box-drawing characters (legacy) + * @deprecated Use renderMarkdownThemedStyled() for new code + */ + +/** + * Main render function - uses shared implementation with TUI + */ +export function renderMarkdown(md: string, options: TerminalMarkdownOptions = {}): string { + const cols = options.cols ?? process.stdout.columns ?? 80 + const colors = options.colors ?? true + + if (!colors) { + return renderMarkdownSimple(md, options) + } + + const theme = options.theme ?? createDefaultCliTheme() + const styledText = renderMarkdownThemedStyled(md, theme, { cols }) + return textChunksToAnsi(styledText.chunks) +} + +/** + * Simple markdown renderer without colors (fallback for colors: false) + */ +function renderMarkdownSimple(md: string, options: TerminalMarkdownOptions = {}): string { + const cols = options.cols ?? process.stdout.columns ?? 80 + const indent = options.indent ?? " " + const listPrefix = options.listPrefix ?? "- " + + const lines = md.split("\n") + const result: string[] = [] + let i = 0 + let inCodeBlock = false + let codeBlockContent: string[] = [] + + while (i < lines.length) { + const line = lines[i] + + if (line.startsWith("```")) { + if (inCodeBlock) { + result.push(indent + codeBlockContent.join("\n" + indent)) + codeBlockContent = [] + inCodeBlock = false + } else { + inCodeBlock = true + } + i++ + continue + } + + if (inCodeBlock) { + codeBlockContent.push(line) + i++ + continue + } + + const headerMatch = line.match(/^(#{1,6})\s+(.+)$/) + if (headerMatch) { + result.push("\n" + headerMatch[2] + "\n") + i++ + continue + } + + if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) { + result.push("\n" + Box.horizontal.repeat(Math.min(cols - 4, 60)) + "\n") + i++ + continue + } + + const listMatch = line.match(/^(\s*)[-*+]\s+(.+)$/) + if (listMatch) { + const depth = Math.floor(listMatch[1].length / 2) + result.push(indent.repeat(depth) + listPrefix + listMatch[2]) + i++ + continue + } + + result.push(line) + i++ + } + + return result.join("\n") +} + +/** + * Simple function to render markdown (drop-in replacement for UI.markdown) + +/** + * Transform only tables in markdown to box-drawing format. + * If borderColor is provided, ANSI escape codes are added for coloring. + +/** + * Convert RGB values to ANSI 24-bit color escape sequence + */ +export function rgbToAnsi(r: number, g: number, b: number): string { + return `\x1b[38;2;${r};${g};${b}m` +} + +/** + * Convert TextChunks to ANSI string for CLI output + */ +function textChunksToAnsi(chunks: TextChunk[]): string { + let result = "" + for (const chunk of chunks) { + let codes = "" + + if (chunk.attributes) { + if (chunk.attributes & Attr.BOLD) codes += "\x1b[1m" + if (chunk.attributes & Attr.DIM) codes += "\x1b[2m" + if (chunk.attributes & Attr.ITALIC) codes += "\x1b[3m" + if (chunk.attributes & Attr.UNDERLINE) codes += "\x1b[4m" + if (chunk.attributes & Attr.BLINK) codes += "\x1b[5m" + if (chunk.attributes & Attr.INVERSE) codes += "\x1b[7m" + if (chunk.attributes & Attr.HIDDEN) codes += "\x1b[8m" + if (chunk.attributes & Attr.STRIKETHROUGH) codes += "\x1b[9m" + } + + if (chunk.fg) { + codes += rgbToAnsi(chunk.fg.r, chunk.fg.g, chunk.fg.b) + } + + if (chunk.bg) { + codes += `\x1b[48;2;${chunk.bg.r};${chunk.bg.g};${chunk.bg.b}m` + } + + result += codes + chunk.text + (codes ? "\x1b[0m" : "") + } + return result +} + +/** + * Create a default CLI theme with ANSI-approximated colors + */ +function createDefaultCliTheme(): MarkdownTheme { + const rgb = (r: number, g: number, b: number) => ({ r: r / 255, g: g / 255, b: b / 255, a: 1.0 }) + + return { + text: rgb(229, 229, 229), + textMuted: rgb(102, 102, 102), + accent: rgb(36, 114, 200), + primary: rgb(36, 114, 200), + border: rgb(102, 102, 102), + background: rgb(0, 0, 0), + backgroundPanel: rgb(60, 60, 60), + backgroundElement: rgb(40, 40, 40), + markdownText: rgb(229, 229, 229), + markdownHeading: rgb(229, 229, 229), + markdownLink: rgb(36, 114, 200), + markdownLinkText: rgb(17, 168, 205), + markdownCode: rgb(13, 188, 121), + markdownCodeBlock: rgb(229, 229, 229), + markdownBlockQuote: rgb(229, 229, 16), + markdownEmph: rgb(229, 229, 16), + markdownStrong: rgb(229, 229, 229), + markdownListItem: rgb(36, 114, 200), + markdownListEnumeration: rgb(17, 168, 205), + markdownHorizontalRule: rgb(102, 102, 102), + diffAdded: rgb(13, 188, 121), + diffRemoved: rgb(205, 49, 49), + } +} + +/** + * TextChunk interface matching OpenTUI's format + */ +export interface TextChunk { + __isChunk: true + text: string + fg?: { r: number; g: number; b: number; a: number } + bg?: { r: number; g: number; b: number; a: number } + attributes?: number +} + +/** + * StyledText class matching OpenTUI's format + */ +export class StyledText { + chunks: TextChunk[] + constructor(chunks: TextChunk[]) { + this.chunks = chunks + } +} + +// Standard ANSI colors (0-15) +const AnsiColors: Record = { + // Normal colors (30-37) + 30: [0, 0, 0], // black + 31: [205, 49, 49], // red + 32: [13, 188, 121], // green + 33: [229, 229, 16], // yellow + 34: [36, 114, 200], // blue + 35: [188, 63, 188], // magenta + 36: [17, 168, 205], // cyan + 37: [229, 229, 229], // white + // Bright colors (90-97) + 90: [102, 102, 102], // bright black (gray) + 91: [241, 76, 76], // bright red + 92: [35, 209, 139], // bright green + 93: [245, 245, 67], // bright yellow + 94: [59, 142, 234], // bright blue + 95: [214, 112, 214], // bright magenta + 96: [41, 184, 219], // bright cyan + 97: [229, 229, 229], // bright white +} + +// Background colors (40-47, 100-107) +const AnsiBgColors: Record = { + 40: [0, 0, 0], + 41: [205, 49, 49], + 42: [13, 188, 121], + 43: [229, 229, 16], + 44: [36, 114, 200], + 45: [188, 63, 188], + 46: [17, 168, 205], + 47: [229, 229, 229], + 100: [102, 102, 102], + 101: [241, 76, 76], + 102: [35, 209, 139], + 103: [245, 245, 67], + 104: [59, 142, 234], + 105: [214, 112, 214], + 106: [41, 184, 219], + 107: [229, 229, 229], +} + +// Text attribute flags (must match OpenTUI's TextAttributes exactly) +const Attr = { + BOLD: 1 << 0, // 1 + DIM: 1 << 1, // 2 + ITALIC: 1 << 2, // 4 + UNDERLINE: 1 << 3, // 8 + BLINK: 1 << 4, // 16 + INVERSE: 1 << 5, // 32 + HIDDEN: 1 << 6, // 64 + STRIKETHROUGH: 1 << 7, // 128 +} as const + +/** + * Parse ANSI escape codes and convert to StyledText with TextChunks + */ +export function ansiToStyledText(input: string): StyledText { + const chunks: TextChunk[] = [] + const regex = /\x1b\[([0-9;]*)m/g + + let fg: { r: number; g: number; b: number; a: number } | undefined + let bg: { r: number; g: number; b: number; a: number } | undefined + let attributes = 0 + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = regex.exec(input)) !== null) { + // Add text before this escape sequence + if (match.index > lastIndex) { + const text = input.slice(lastIndex, match.index) + if (text) { + chunks.push({ + __isChunk: true, + text, + fg, + bg, + attributes, + }) + } + } + + // Parse the escape sequence + const codes = match[1].split(";").map(Number) + let i = 0 + + while (i < codes.length) { + const code = codes[i] + + if (code === 0) { + // Reset + fg = undefined + bg = undefined + attributes = 0 + } else if (code === 1) { + attributes |= Attr.BOLD + } else if (code === 2) { + attributes |= Attr.DIM + } else if (code === 3) { + attributes |= Attr.ITALIC + } else if (code === 4) { + attributes |= Attr.UNDERLINE + } else if (code === 5) { + attributes |= Attr.BLINK + } else if (code === 7) { + attributes |= Attr.INVERSE + } else if (code === 9) { + attributes |= Attr.STRIKETHROUGH + } else if (code >= 30 && code <= 37) { + // Standard foreground + const rgb = AnsiColors[code] + fg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + } else if (code >= 40 && code <= 47) { + // Standard background + const rgb = AnsiBgColors[code] + bg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + } else if (code >= 90 && code <= 97) { + // Bright foreground + const rgb = AnsiColors[code] + fg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + } else if (code >= 100 && code <= 107) { + // Bright background + const rgb = AnsiBgColors[code] + bg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + } else if (code === 38 && codes[i + 1] === 2) { + // 24-bit foreground: 38;2;r;g;b + const r = codes[i + 2] ?? 0 + const g = codes[i + 3] ?? 0 + const b = codes[i + 4] ?? 0 + fg = { r, g, b, a: 255 } + i += 4 + } else if (code === 48 && codes[i + 1] === 2) { + // 24-bit background: 48;2;r;g;b + const r = codes[i + 2] ?? 0 + const g = codes[i + 3] ?? 0 + const b = codes[i + 4] ?? 0 + bg = { r, g, b, a: 255 } + i += 4 + } else if (code === 38 && codes[i + 1] === 5) { + // 256-color foreground: 38;5;n + const n = codes[i + 2] ?? 0 + const rgb = ansi256ToRgb(n) + fg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + i += 2 + } else if (code === 48 && codes[i + 1] === 5) { + // 256-color background: 48;5;n + const n = codes[i + 2] ?? 0 + const rgb = ansi256ToRgb(n) + bg = { r: rgb[0], g: rgb[1], b: rgb[2], a: 255 } + i += 2 + } else if (code === 39) { + // Default foreground + fg = undefined + } else if (code === 49) { + // Default background + bg = undefined + } + + i++ + } + + lastIndex = regex.lastIndex + } + + // Add remaining text + if (lastIndex < input.length) { + const text = input.slice(lastIndex) + if (text) { + chunks.push({ + __isChunk: true, + text, + fg, + bg, + attributes, + }) + } + } + + return new StyledText(chunks) +} + +/** + * Convert 256-color index to RGB + */ +function ansi256ToRgb(n: number): [number, number, number] { + if (n < 16) { + // Standard colors + const colors: [number, number, number][] = [ + [0, 0, 0], + [128, 0, 0], + [0, 128, 0], + [128, 128, 0], + [0, 0, 128], + [128, 0, 128], + [0, 128, 128], + [192, 192, 192], + [128, 128, 128], + [255, 0, 0], + [0, 255, 0], + [255, 255, 0], + [0, 0, 255], + [255, 0, 255], + [0, 255, 255], + [255, 255, 255], + ] + return colors[n] + } else if (n < 232) { + // 216-color cube (6x6x6) + const idx = n - 16 + const r = Math.floor(idx / 36) + const g = Math.floor((idx % 36) / 6) + const b = idx % 6 + return [r ? r * 40 + 55 : 0, g ? g * 40 + 55 : 0, b ? b * 40 + 55 : 0] + } else { + // Grayscale (24 levels) + const gray = (n - 232) * 10 + 8 + return [gray, gray, gray] + } +} + +/** + +/** + * RGBA color type matching OpenTUI's format + */ +type RGBA = { r: number; g: number; b: number; a: number } + +/** + * Segment types for parsed markdown + */ +export type MarkdownSegment = { type: "text"; content: string } | { type: "code"; content: string; language: string } + +/** + * Parse markdown into segments - separates code blocks from other content + * This allows code blocks to be rendered with tree-sitter highlighting + */ +export function parseMarkdownSegments(md: string): MarkdownSegment[] { + const segments: MarkdownSegment[] = [] + const lines = md.split("\n") + let currentText: string[] = [] + let inCodeBlock = false + let codeBlockLang = "" + let codeBlockContent: string[] = [] + + const flushText = () => { + if (currentText.length > 0) { + segments.push({ type: "text", content: currentText.join("\n") }) + currentText = [] + } + } + + for (const line of lines) { + const trimmed = line.trim() + + if (trimmed.startsWith("```")) { + if (inCodeBlock) { + // End code block + segments.push({ + type: "code", + content: codeBlockContent.join("\n"), + language: codeBlockLang, + }) + codeBlockContent = [] + inCodeBlock = false + codeBlockLang = "" + } else { + // Start code block + flushText() + inCodeBlock = true + codeBlockLang = trimmed.slice(3).trim() || "text" + } + continue + } + + if (inCodeBlock) { + codeBlockContent.push(line) + } else { + currentText.push(line) + } + } + + // Flush remaining text + flushText() + + // Handle unclosed code block + if (inCodeBlock && codeBlockContent.length > 0) { + segments.push({ + type: "code", + content: codeBlockContent.join("\n"), + language: codeBlockLang, + }) + } + + return segments +} + +/** + * TUI Theme RGBA type (0-1 float values) + */ +type TuiRGBA = { r: number; g: number; b: number; a: number } + +/** + * Theme interface - accepts TUI theme directly (RGBA 0-1 floats) + * The renderer will convert to 0-255 internally + */ +export interface MarkdownTheme { + // Core colors + text: TuiRGBA + textMuted: TuiRGBA + accent: TuiRGBA + primary: TuiRGBA + border: TuiRGBA + background: TuiRGBA + backgroundPanel: TuiRGBA + backgroundElement: TuiRGBA + + // Markdown specific + markdownText: TuiRGBA + markdownHeading: TuiRGBA + markdownLink: TuiRGBA + markdownLinkText: TuiRGBA + markdownCode: TuiRGBA + markdownCodeBlock: TuiRGBA + markdownBlockQuote: TuiRGBA + markdownEmph: TuiRGBA + markdownStrong: TuiRGBA + markdownListItem: TuiRGBA + markdownListEnumeration: TuiRGBA + markdownHorizontalRule: TuiRGBA + + // Diff colors + diffAdded: TuiRGBA + diffRemoved: TuiRGBA +} + +// Convert TUI RGBA (0-1 floats) to internal RGBA (0-255 ints) +function toIntRGBA(rgba: TuiRGBA): RGBA { + return { + r: Math.round(rgba.r * 255), + g: Math.round(rgba.g * 255), + b: Math.round(rgba.b * 255), + a: Math.round(rgba.a * 255), + } +} + +/** + * Render markdown directly to TextChunks using theme colors (no ANSI intermediate) + */ +export function renderMarkdownThemedStyled( + md: string, + tuiTheme: MarkdownTheme, + options: { cols?: number } = {}, +): StyledText { + const cols = options.cols ?? process.stdout.columns ?? 80 + const chunks: TextChunk[] = [] + + // Convert TUI theme (0-1 floats) to internal format (0-255 ints) + const theme = { + text: toIntRGBA(tuiTheme.text), + textMuted: toIntRGBA(tuiTheme.textMuted), + accent: toIntRGBA(tuiTheme.accent), + primary: toIntRGBA(tuiTheme.primary), + border: toIntRGBA(tuiTheme.border), + background: toIntRGBA(tuiTheme.background), + backgroundPanel: toIntRGBA(tuiTheme.backgroundPanel), + backgroundElement: toIntRGBA(tuiTheme.backgroundElement), + markdownText: toIntRGBA(tuiTheme.markdownText), + markdownHeading: toIntRGBA(tuiTheme.markdownHeading), + markdownLink: toIntRGBA(tuiTheme.markdownLink), + markdownLinkText: toIntRGBA(tuiTheme.markdownLinkText), + markdownCode: toIntRGBA(tuiTheme.markdownCode), + markdownCodeBlock: toIntRGBA(tuiTheme.markdownCodeBlock), + markdownBlockQuote: toIntRGBA(tuiTheme.markdownBlockQuote), + markdownEmph: toIntRGBA(tuiTheme.markdownEmph), + markdownStrong: toIntRGBA(tuiTheme.markdownStrong), + markdownListItem: toIntRGBA(tuiTheme.markdownListItem), + markdownListEnumeration: toIntRGBA(tuiTheme.markdownListEnumeration), + markdownHorizontalRule: toIntRGBA(tuiTheme.markdownHorizontalRule), + diffAdded: toIntRGBA(tuiTheme.diffAdded), + diffRemoved: toIntRGBA(tuiTheme.diffRemoved), + } + + const addChunk = (text: string, color?: RGBA, attrs: number = 0) => { + if (text) { + chunks.push({ + __isChunk: true, + text, + fg: color, + attributes: attrs, + }) + } + } + + const addChunkWithBg = (text: string, fg?: RGBA, bg?: RGBA, attrs: number = 0) => { + if (text) { + chunks.push({ + __isChunk: true, + text, + fg, + bg, + attributes: attrs, + }) + } + } + + const lines = md.split("\n") + let inCodeBlock = false + let codeBlockLang = "" + let inTable = false + let tableLines: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + + // Code block fence + if (trimmed.startsWith("```")) { + if (inCodeBlock) { + inCodeBlock = false + codeBlockLang = "" + continue + } + inCodeBlock = true + codeBlockLang = trimmed.slice(3).trim() + continue + } + + // Inside code block - handle diff specially + if (inCodeBlock) { + if (codeBlockLang === "diff") { + // Diff-style code with colors + if (trimmed.startsWith("+")) { + addChunk(" " + line + "\n", theme.diffAdded) + } else if (trimmed.startsWith("-")) { + addChunk(" " + line + "\n", theme.diffRemoved) + } else { + addChunk(" " + line + "\n", theme.markdownCodeBlock) + } + } else { + addChunk(" " + line + "\n", theme.markdownCodeBlock, Attr.ITALIC) + } + continue + } + + // Table detection + if (trimmed.startsWith("|") && trimmed.endsWith("|")) { + if (!inTable) { + inTable = true + tableLines = [] + } + tableLines.push(trimmed) + // Check if next line is not a table line or end of input + const nextLine = lines[i + 1]?.trim() + if (!nextLine || !nextLine.startsWith("|") || !nextLine.endsWith("|")) { + // End of table, render it + renderTableThemed(tableLines, theme, chunks, cols) + inTable = false + tableLines = [] + } + continue + } + + // Headers - prompt-style with thick vertical bar (like the prompt input) + const headerMatch = trimmed.match(/^(#{1,6})\s+(.*)$/) + if (headerMatch) { + const level = headerMatch[1].length + const text = headerMatch[2] + + // Use left half block for the accent bar (like prompt) + const bar = "\u258c" // ▌ left half block + + if (level <= 2) { + // h1/h2: prominent with grey bar + background + // addChunk(bar, theme.border) + addChunkWithBg(" ", theme.markdownHeading, theme.backgroundPanel, Attr.BOLD) + renderInlineThemedWithDefault(text, theme, chunks, theme.markdownHeading, Attr.BOLD) + addChunkWithBg(" \n", theme.markdownHeading, theme.backgroundPanel, Attr.BOLD) + } else { + // h3+: just bold text, less visual weight - process inline markdown + renderInlineThemedWithDefault(text, theme, chunks, theme.markdownHeading, Attr.BOLD) + addChunk("\n") + } + continue + } + + // Horizontal rule + if (/^(-{3,}|_{3,}|\*{3,})$/.test(trimmed)) { + addChunk(Box.horizontal.repeat(cols - 4) + "\n", theme.markdownHorizontalRule, Attr.DIM) + continue + } + + // Blockquote + if (trimmed.startsWith(">")) { + const content = trimmed.replace(/^>\s*/, "") + addChunk(" " + Box.vertical + " ", theme.border, Attr.DIM) + // Process inline formatting within blockquote + renderInlineThemedWithDefault(content, theme, chunks, theme.markdownBlockQuote, Attr.ITALIC) + addChunk("\n") + continue + } + + // Task list item with checkbox + const taskMatch = trimmed.match(/^[-*+]\s+\[([ xX])\]\s+(.*)$/) + if (taskMatch) { + const indent = line.match(/^(\s*)/)?.[1] ?? "" + const checked = taskMatch[1].toLowerCase() === "x" + const content = taskMatch[2] + addChunk(indent + "- ", theme.markdownListItem) + addChunk("[", theme.markdownListItem) + addChunk(checked ? "x" : " ", checked ? theme.diffAdded : theme.textMuted) + addChunk("] ", theme.markdownListItem) + renderInlineThemed(content, theme, chunks) + addChunk("\n") + continue + } + + // Unordered list + const ulMatch = trimmed.match(/^[-*+]\s+(.*)$/) + if (ulMatch) { + const indent = line.match(/^(\s*)/)?.[1] ?? "" + const content = ulMatch[1] + addChunk(indent + "- ", theme.markdownListItem) + renderInlineThemed(content, theme, chunks) + addChunk("\n") + continue + } + + // Ordered list + const olMatch = trimmed.match(/^(\d+)[.)]\s+(.*)$/) + if (olMatch) { + const indent = line.match(/^(\s*)/)?.[1] ?? "" + const num = olMatch[1] + const content = olMatch[2] + addChunk(indent + num + ". ", theme.markdownListEnumeration) + renderInlineThemed(content, theme, chunks) + addChunk("\n") + continue + } + + // Empty line + if (trimmed === "") { + addChunk("\n") + continue + } + + // Regular text with inline formatting + renderInlineThemed(line, theme, chunks) + addChunk("\n") + } + + return new StyledText(chunks) +} + +/** + * Render inline markdown elements (bold, italic, code, links) with theme colors + */ +function renderInlineThemed(text: string, theme: MarkdownTheme, chunks: TextChunk[]) { + renderInlineThemedWithDefault(text, theme, chunks, theme.markdownText, 0) +} + +/** + * Render inline markdown with a default color/attribute for plain text + */ +function renderInlineThemedWithDefault( + text: string, + theme: MarkdownTheme, + chunks: TextChunk[], + defaultColor: RGBA, + defaultAttrs: number, +) { + const addChunk = (t: string, color?: RGBA, attrs: number = 0) => { + if (t) { + chunks.push({ + __isChunk: true, + text: t, + fg: color, + attributes: attrs, + }) + } + } + + // Process inline elements with regex + let lastIndex = 0 + + // Combined regex for inline elements - order matters + // Matches: ***bold italic***, **bold *with italic* inside**, *italic **with bold** inside*, `code`, [text](url "title"), ~~strikethrough~~ + const inlineRegex = + /(\*\*\*|___)(.*?)\1|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|~~(.+?)~~/g + + let match + while ((match = inlineRegex.exec(text)) !== null) { + // Add text before this match + if (match.index > lastIndex) { + addChunk(text.slice(lastIndex, match.index), defaultColor, defaultAttrs) + } + + if (match[1]) { + // Bold+Italic (*** or ___) + addChunk(match[2], theme.markdownStrong, Attr.BOLD | Attr.ITALIC) + } else if (match[3] !== undefined) { + // Bold (**) - may contain nested italic + const boldContent = match[3] + // Check for nested italic inside bold + const nestedItalic = boldContent.match(/^(.*)?\*(.+?)\*(.*)$/) + if (nestedItalic) { + if (nestedItalic[1]) addChunk(nestedItalic[1], theme.markdownStrong, Attr.BOLD) + addChunk(nestedItalic[2], theme.markdownStrong, Attr.BOLD | Attr.ITALIC) + if (nestedItalic[3]) addChunk(nestedItalic[3], theme.markdownStrong, Attr.BOLD) + } else { + addChunk(boldContent, theme.markdownStrong, Attr.BOLD) + } + } else if (match[4] !== undefined) { + // Italic (*) - may contain nested bold + const italicContent = match[4] + // Check for nested bold inside italic + const nestedBold = italicContent.match(/^(.*)?\*\*(.+?)\*\*(.*)$/) + if (nestedBold) { + if (nestedBold[1]) addChunk(nestedBold[1], theme.markdownEmph, Attr.ITALIC) + addChunk(nestedBold[2], theme.markdownStrong, Attr.BOLD | Attr.ITALIC) + if (nestedBold[3]) addChunk(nestedBold[3], theme.markdownEmph, Attr.ITALIC) + } else { + addChunk(italicContent, theme.markdownEmph, Attr.ITALIC) + } + } else if (match[5] !== undefined) { + // Inline code + addChunk(match[5], theme.markdownCode) + } else if (match[6] !== undefined) { + // Link [text](url "title") - show text and URL like original + const linkText = match[6] + const url = match[7] + addChunk(linkText, theme.markdownLinkText, Attr.UNDERLINE) + addChunk(" (", theme.markdownText) + addChunk(url, theme.markdownLink, Attr.UNDERLINE) + addChunk(")", theme.markdownText) + } else if (match[8] !== undefined) { + // Strikethrough ~~text~~ - use muted color with strikethrough attribute + addChunk(match[8], theme.textMuted, Attr.STRIKETHROUGH) + } + + lastIndex = match.index + match[0].length + } + + // Add remaining text + if (lastIndex < text.length) { + addChunk(text.slice(lastIndex), defaultColor, defaultAttrs) + } +} + +/** + * Render table with theme colors + */ +function renderTableThemed(tableLines: string[], theme: MarkdownTheme, chunks: TextChunk[], cols: number) { + if (tableLines.length < 2) return + + const addChunk = (t: string, color?: { r: number; g: number; b: number; a: number }, attrs: number = 0) => { + if (t) { + chunks.push({ + __isChunk: true, + text: t, + fg: color, + attributes: attrs, + }) + } + } + + // Parse table + const parseRow = (row: string): string[] => { + // Split on | but not \|, then unescape any \| to | + const cells: string[] = [] + let cell = "" + let i = 0 + + while (i < row.length) { + if (row[i] === "\\" && row[i + 1] === "|") { + // Escaped pipe - add literal | and skip backslash + cell += "|" + i += 2 + } else if (row[i] === "|") { + // Unescaped pipe - cell boundary + cells.push(cell) + cell = "" + i++ + } else { + cell += row[i] + i++ + } + } + cells.push(cell) + + return cells.slice(1, -1).map((c) => c.trim()) + } + + // Check if a line is a separator line (contains only dashes, colons, pipes, spaces) + const isSeparatorLine = (line: string): boolean => { + return /^\|[\s\-:|\s]+\|$/.test(line) + } + + // Calculate visible length (after markdown rendering) + // Strips markdown syntax: **bold**, *italic*, `code`, ~~strike~~, [text](url) + // Uses Bun.stringWidth() to account for double-width unicode chars (emojis, CJK) + const visibleLength = (text: string): number => { + const stripped = text + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") + return Bun.stringWidth(stripped) + } + + // Word wrap text to fit width, returns array of lines + // Preserves markdown syntax by not breaking inside backticks + // Handles
tags as explicit line breaks + // Post-processes lines to ensure bold markers are complete on each line + const wordWrap = (text: string, width: number): string[] => { + // First, handle
tags by splitting into segments + const brSegments = text.split(//gi) + + // Process each segment and collect all lines + const allLines: string[] = [] + + for (const segment of brSegments) { + const segmentTrimmed = segment.trim() + if (!segmentTrimmed) { + allLines.push("") + continue + } + + if (visibleLength(segmentTrimmed) <= width) { + allLines.push(segmentTrimmed) + continue + } + + // Split into tokens, keeping backtick-quoted sections together + const tokens: string[] = [] + let current = "" + let inBacktick = false + + for (let i = 0; i < segmentTrimmed.length; i++) { + const char = segmentTrimmed[i] + if (char === "`") { + inBacktick = !inBacktick + current += char + } else if (char === " " && !inBacktick) { + if (current) tokens.push(current) + tokens.push(" ") + current = "" + } else { + current += char + } + } + if (current) tokens.push(current) + + // Break a long token on / - characters if it doesn't fit + // Recursively breaks until all parts fit within maxLen + const breakLongToken = (token: string, maxLen: number): string[] => { + if (visibleLength(token) <= maxLen) return [token] + + // Don't break backtick-quoted tokens + if (token.startsWith("`") && token.endsWith("`")) return [token] + + // Try to find a break point + let bestBreak = -1 + for (let i = 0; i < token.length - 1; i++) { + if ("/-".includes(token[i]) && visibleLength(token.slice(0, i + 1)) <= maxLen) { + bestBreak = i + } + } + + if (bestBreak === -1) { + // No good break point found, return as-is + return [token] + } + + const firstPart = token.slice(0, bestBreak + 1) + const rest = token.slice(bestBreak + 1) + + // Recursively break the rest if still too long + if (visibleLength(rest) > maxLen) { + return [firstPart, ...breakLongToken(rest, maxLen)] + } + + return [firstPart, rest] + } + + // Simple word wrap - fit tokens into lines, breaking long tokens if needed + const lines: string[] = [] + let line = "" + + const addToken = (token: string) => { + if (visibleLength(token) > width) { + // Token too long, need to break it + const parts = breakLongToken(token, width) + for (const part of parts) { + if (visibleLength(line) === 0) { + line = part + } else if (visibleLength(line + part) <= width) { + line += part + } else { + lines.push(line.trimEnd()) + line = part + } + } + } else if (visibleLength(line) === 0) { + line = token + } else if (visibleLength(line + token) <= width) { + line += token + } else { + lines.push(line.trimEnd()) + line = token + } + } + + for (const token of tokens) { + if (token === " ") { + if (visibleLength(line) > 0 && visibleLength(line) < width) { + line += " " + } + } else { + addToken(token) + } + } + if (line) lines.push(line.trimEnd()) + + // Post-process: fix bold markers across line breaks + // Each line should have complete **...** pairs for renderInlineThemed to work + const countBoldMarkers = (s: string): number => (s.match(/\*\*/g) || []).length + + let inBold = false + for (let i = 0; i < lines.length; i++) { + let l = lines[i] + const markers = countBoldMarkers(l) + + // If we're in bold from previous line, prepend ** + if (inBold && !l.startsWith("**")) { + l = "**" + l + } + + // Recount after potential prepend + const newMarkers = countBoldMarkers(l) + const lineEndsInBold = (newMarkers % 2 === 1) + + // If line ends in bold (odd markers), close it and mark for next line + if (lineEndsInBold) { + if (!l.endsWith("**")) { + l = l + "**" + } + inBold = true + } else { + // Even markers - check if we started in bold + // If we started in bold and have even markers, we're still in bold + inBold = inBold && (markers % 2 === 0) + } + + lines[i] = l + } + + allLines.push(...(lines.length ? lines : [""])) + } + + return allLines.length ? allLines : [""] + } + + const headerRow = parseRow(tableLines[0]) + // Filter out separator lines and parse remaining as data rows + const dataRows = tableLines + .slice(1) + .filter((line) => !isSeparatorLine(line)) + .map(parseRow) + + // Calculate column widths - start with natural widths (using visible width with unicode) + let colWidths = headerRow.map((h, i) => { + const dataMax = Math.max(...dataRows.map((r) => visibleLength(r[i] ?? "")), 0) + return Math.max(visibleLength(h), dataMax) + }) + + // Constrain table to available width (cols - 4 for padding) + const maxWidth = cols - 4 + // Total width = sum of colWidths + 3 per col (2 padding + 1 border) + 1 (final border) + const calcWidth = () => colWidths.reduce((a, b) => a + b + 3, 1) + + // Shrink columns proportionally if table is too wide + while (calcWidth() > maxWidth && colWidths.some((w) => w > 10)) { + const maxIdx = colWidths.indexOf(Math.max(...colWidths)) + colWidths[maxIdx] = Math.max(10, colWidths[maxIdx] - 1) + } + + // Top border + addChunk( + Box.topLeft + colWidths.map((w) => Box.horizontal.repeat(w + 2)).join(Box.topT) + Box.topRight + "\n", + theme.border, + ) + + // Header row (single line, no wrap for headers) + addChunk(Box.vertical, theme.border) + headerRow.forEach((cell, i) => { + addChunk(" ", theme.border) + const cellWidth = Bun.stringWidth(cell) + const targetWidth = colWidths[i] + if (cellWidth <= targetWidth) { + // Pad with spaces + addChunk(cell + " ".repeat(targetWidth - cellWidth), theme.markdownHeading, Attr.BOLD) + } else { + // Truncate - simple approach, may cut mid-emoji + addChunk(cell.slice(0, targetWidth), theme.markdownHeading, Attr.BOLD) + } + addChunk(" " + Box.vertical, theme.border) + }) + addChunk("\n") + + // Header separator + addChunk( + Box.leftT + colWidths.map((w) => Box.horizontal.repeat(w + 2)).join(Box.cross) + Box.rightT + "\n", + theme.border, + ) + + // Helper to render cell with inline formatting and pad to width + const renderCell = (text: string, width: number, isHeader: boolean) => { + if (!text) { + addChunk(" ".repeat(width), theme.markdownText) + return + } + // Render inline markdown to temporary chunks + const cellChunks: TextChunk[] = [] + if (isHeader) { + cellChunks.push({ __isChunk: true, text, fg: theme.markdownHeading, attributes: Attr.BOLD }) + } else { + renderInlineThemed(text, theme, cellChunks) + } + // Calculate visible length (accounting for double-width unicode) and add chunks + let len = 0 + for (const c of cellChunks) { + chunks.push(c) + len += Bun.stringWidth(c.text) + } + // Pad remaining space + if (len < width) { + addChunk(" ".repeat(width - len), theme.markdownText) + } + } + + // Data rows with word wrap + dataRows.forEach((row) => { + // Wrap each cell and find max lines needed + const wrappedCells = row.map((cell, i) => wordWrap(cell ?? "", colWidths[i] ?? 10)) + const maxLines = Math.max(...wrappedCells.map((w) => w.length)) + + // Render each line of the row + for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { + addChunk(Box.vertical, theme.border) + wrappedCells.forEach((lines, i) => { + addChunk(" ", theme.border) + const text = lines[lineIdx] ?? "" + renderCell(text, colWidths[i] ?? 0, false) + addChunk(" " + Box.vertical, theme.border) + }) + addChunk("\n") + } + }) + + // Bottom border + addChunk( + Box.bottomLeft + colWidths.map((w) => Box.horizontal.repeat(w + 2)).join(Box.bottomT) + Box.bottomRight + "\n", + theme.border, + ) +} + +export { Box, Ansi, Theme } diff --git a/packages/opencode/src/cli/theme-loader.ts b/packages/opencode/src/cli/theme-loader.ts new file mode 100644 index 00000000000..70daa643356 --- /dev/null +++ b/packages/opencode/src/cli/theme-loader.ts @@ -0,0 +1,98 @@ +/** + * Theme loader for CLI - loads theme JSON files and converts to MarkdownTheme format + */ +import type { MarkdownTheme } from "./markdown-renderer" +import opencode from "./cmd/tui/context/theme/opencode.json" with { type: "json" } +import aura from "./cmd/tui/context/theme/aura.json" with { type: "json" } +import ayu from "./cmd/tui/context/theme/ayu.json" with { type: "json" } +import catppuccin from "./cmd/tui/context/theme/catppuccin.json" with { type: "json" } +import catppuccinFrappe from "./cmd/tui/context/theme/catppuccin-frappe.json" with { type: "json" } +import catppuccinMacchiato from "./cmd/tui/context/theme/catppuccin-macchiato.json" with { type: "json" } +import dracula from "./cmd/tui/context/theme/dracula.json" with { type: "json" } +import github from "./cmd/tui/context/theme/github.json" with { type: "json" } +import gruvbox from "./cmd/tui/context/theme/gruvbox.json" with { type: "json" } +import nord from "./cmd/tui/context/theme/nord.json" with { type: "json" } +import tokyonight from "./cmd/tui/context/theme/tokyonight.json" with { type: "json" } +import vercel from "./cmd/tui/context/theme/vercel.json" with { type: "json" } + +const THEMES: Record = { + opencode, + aura, + ayu, + catppuccin, + "catppuccin-frappe": catppuccinFrappe, + "catppuccin-macchiato": catppuccinMacchiato, + dracula, + github, + gruvbox, + nord, + tokyonight, + vercel, +} + +type ColorValue = string | { dark: string; light: string } + +/** + * Resolve a color value from theme defs + */ +function resolveColor(value: ColorValue, defs: Record, mode: "dark" | "light"): string { + if (typeof value === "string") { + // Check if it's a reference to a def + return defs[value] || value + } + // Mode-specific color + const colorKey = mode === "dark" ? value.dark : value.light + return defs[colorKey] || colorKey +} + +/** + * Convert hex color to RGBA format (0-1 floats) + */ +function hexToRGBA(hex: string): { r: number; g: number; b: number; a: number } { + const cleaned = hex.replace("#", "") + const r = Number.parseInt(cleaned.substring(0, 2), 16) / 255 + const g = Number.parseInt(cleaned.substring(2, 4), 16) / 255 + const b = Number.parseInt(cleaned.substring(4, 6), 16) / 255 + return { r, g, b, a: 1.0 } +} + +/** + * Load a theme by name and convert to MarkdownTheme format + */ +export function loadTheme(themeName?: string, mode: "dark" | "light" = "dark"): MarkdownTheme { + const themeData = THEMES[themeName || "opencode"] || THEMES.opencode + const defs = themeData.defs || {} + const theme = themeData.theme || {} + + const resolve = (key: string) => { + const value = theme[key] + if (!value) return { r: 1, g: 1, b: 1, a: 1 } // Default white + const hex = resolveColor(value, defs, mode) + return hexToRGBA(hex) + } + + return { + text: resolve("text"), + textMuted: resolve("textMuted"), + accent: resolve("accent"), + primary: resolve("primary"), + border: resolve("border"), + background: resolve("background"), + backgroundPanel: resolve("backgroundPanel"), + backgroundElement: resolve("backgroundElement"), + markdownText: resolve("markdownText"), + markdownHeading: resolve("markdownHeading"), + markdownLink: resolve("markdownLink"), + markdownLinkText: resolve("markdownLinkText"), + markdownCode: resolve("markdownCode"), + markdownCodeBlock: resolve("markdownCodeBlock"), + markdownBlockQuote: resolve("markdownBlockQuote"), + markdownEmph: resolve("markdownEmph"), + markdownStrong: resolve("markdownStrong"), + markdownListItem: resolve("markdownListItem"), + markdownListEnumeration: resolve("markdownListEnumeration"), + markdownHorizontalRule: resolve("markdownHorizontalRule"), + diffAdded: resolve("diffAdded"), + diffRemoved: resolve("diffRemoved"), + } +} diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 9df1f4ac550..c8c3ea8c454 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,7 @@ import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/util/error" +import { renderMarkdown, type MarkdownTheme } from "./markdown-renderer" import { logo as glyphs } from "./logo" export namespace UI { @@ -107,7 +108,7 @@ export namespace UI { println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) } - export function markdown(text: string): string { - return text + export function markdown(text: string, theme?: MarkdownTheme): string { + return renderMarkdown(text, { theme }) } } diff --git a/packages/sdk/js/src/v2/gen/client/client.gen.ts b/packages/sdk/js/src/v2/gen/client/client.gen.ts index 627e98ec420..8eea2b63733 100644 --- a/packages/sdk/js/src/v2/gen/client/client.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/client.gen.ts @@ -1,9 +1,14 @@ // This file is auto-generated by @hey-api/openapi-ts -import { createSseClient } from "../core/serverSentEvents.gen.js" -import type { HttpMethod } from "../core/types.gen.js" -import { getValidRequestBody } from "../core/utils.gen.js" -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" +import { createSseClient } from '../core/serverSentEvents.gen.js'; +import type { HttpMethod } from '../core/types.gen.js'; +import { getValidRequestBody } from '../core/utils.gen.js'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen.js'; import { buildUrl, createConfig, @@ -12,24 +17,29 @@ import { mergeConfigs, mergeHeaders, setAuthParams, -} from "./utils.gen.js" +} from './utils.gen.js'; -type ReqInit = Omit & { - body?: any - headers: ReturnType -} +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config) + let _config = mergeConfigs(createConfig(), config); - const getConfig = (): Config => ({ ..._config }) + const getConfig = (): Config => ({ ..._config }); const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config) - return getConfig() - } + _config = mergeConfigs(_config, config); + return getConfig(); + }; - const interceptors = createInterceptors() + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); const beforeRequest = async (options: RequestOptions) => { const opts = { @@ -38,248 +48,264 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), serializedBody: undefined, - } + }; if (opts.security) { await setAuthParams({ ...opts, security: opts.security, - }) + }); } if (opts.requestValidator) { - await opts.requestValidator(opts) + await opts.requestValidator(opts); } if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body) + opts.serializedBody = opts.bodySerializer(opts.body); } // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === "") { - opts.headers.delete("Content-Type") + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); } - const url = buildUrl(opts) + const url = buildUrl(opts); - return { opts, url } - } + return { opts, url }; + }; - const request: Client["request"] = async (options) => { + const request: Client['request'] = async (options) => { // @ts-expect-error - const { opts, url } = await beforeRequest(options) + const { opts, url } = await beforeRequest(options); const requestInit: ReqInit = { - redirect: "follow", + redirect: 'follow', ...opts, body: getValidRequestBody(opts), - } + }; - let request = new Request(url, requestInit) + let request = new Request(url, requestInit); for (const fn of interceptors.request.fns) { if (fn) { - request = await fn(request, opts) + request = await fn(request, opts); } } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch! - let response: Response + const _fetch = opts.fetch!; + let response: Response; try { - response = await _fetch(request) + response = await _fetch(request); } catch (error) { // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, undefined as any, request, opts)) as unknown + finalError = (await fn( + error, + undefined as any, + request, + opts, + )) as unknown; } } - finalError = finalError || ({} as unknown) + finalError = finalError || ({} as unknown); if (opts.throwOnError) { - throw finalError + throw finalError; } // Return error response - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, request, response: undefined as any, - } + }; } for (const fn of interceptors.response.fns) { if (fn) { - response = await fn(response, request, opts) + response = await fn(response, request, opts); } } const result = { request, response, - } + }; if (response.ok) { const parseAs = - (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" - - if (response.status === 204 || response.headers.get("Content-Length") === "0") { - let emptyData: any + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "text": - emptyData = await response[parseAs]() - break - case "formData": - emptyData = new FormData() - break - case "stream": - emptyData = response.body - break - case "json": + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': default: - emptyData = {} - break + emptyData = {}; + break; } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? emptyData : { data: emptyData, ...result, - } + }; } - let data: any + let data: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "formData": - case "text": - data = await response[parseAs]() - break - case "json": { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { // Some servers return 200 with no Content-Length and empty body. // response.json() would throw; read as text and parse if non-empty. - const text = await response.text() - data = text ? JSON.parse(text) : {} - break + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; } - case "stream": - return opts.responseStyle === "data" + case 'stream': + return opts.responseStyle === 'data' ? response.body : { data: response.body, ...result, - } + }; } - if (parseAs === "json") { + if (parseAs === 'json') { if (opts.responseValidator) { - await opts.responseValidator(data) + await opts.responseValidator(data); } if (opts.responseTransformer) { - data = await opts.responseTransformer(data) + data = await opts.responseTransformer(data); } } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? data : { data, ...result, - } + }; } - const textError = await response.text() - let jsonError: unknown + const textError = await response.text(); + let jsonError: unknown; try { - jsonError = JSON.parse(textError) + jsonError = JSON.parse(textError); } catch { // noop } - const error = jsonError ?? textError - let finalError = error + const error = jsonError ?? textError; + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, response, request, opts)) as string + finalError = (await fn(error, response, request, opts)) as string; } } - finalError = finalError || ({} as string) + finalError = finalError || ({} as string); if (opts.throwOnError) { - throw finalError + throw finalError; } // TODO: we probably want to return error and improve types - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, ...result, - } - } + }; + }; - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => request({ ...options, method }) + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); - const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options) - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init) - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts) + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } } - } - return request - }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, - url, - }) - } + return request; + }, + serializedBody: getValidRequestBody(opts) as + | BodyInit + | null + | undefined, + url, + }); + }; return { buildUrl, - connect: makeMethodFn("CONNECT"), - delete: makeMethodFn("DELETE"), - get: makeMethodFn("GET"), + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), getConfig, - head: makeMethodFn("HEAD"), + head: makeMethodFn('HEAD'), interceptors, - options: makeMethodFn("OPTIONS"), - patch: makeMethodFn("PATCH"), - post: makeMethodFn("POST"), - put: makeMethodFn("PUT"), + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), request, setConfig, sse: { - connect: makeSseFn("CONNECT"), - delete: makeSseFn("DELETE"), - get: makeSseFn("GET"), - head: makeSseFn("HEAD"), - options: makeSseFn("OPTIONS"), - patch: makeSseFn("PATCH"), - post: makeSseFn("POST"), - put: makeSseFn("PUT"), - trace: makeSseFn("TRACE"), + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), }, - trace: makeMethodFn("TRACE"), - } as Client -} + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/packages/sdk/js/src/v2/gen/client/index.ts b/packages/sdk/js/src/v2/gen/client/index.ts index 0af63f3300e..50acaa57b71 100644 --- a/packages/sdk/js/src/v2/gen/client/index.ts +++ b/packages/sdk/js/src/v2/gen/client/index.ts @@ -1,15 +1,15 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { Auth } from "../core/auth.gen.js" -export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +export type { Auth } from '../core/auth.gen.js'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from "../core/bodySerializer.gen.js" -export { buildClientParams } from "../core/params.gen.js" -export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js" -export { createClient } from "./client.gen.js" +} from '../core/bodySerializer.gen.js'; +export { buildClientParams } from '../core/params.gen.js'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js'; +export { createClient } from './client.gen.js'; export type { Client, ClientOptions, @@ -21,5 +21,5 @@ export type { ResolvedRequestOptions, ResponseStyle, TDataShape, -} from "./types.gen.js" -export { createConfig, mergeHeaders } from "./utils.gen.js" +} from './types.gen.js'; +export { createConfig, mergeHeaders } from './utils.gen.js'; diff --git a/packages/sdk/js/src/v2/gen/client/types.gen.ts b/packages/sdk/js/src/v2/gen/client/types.gen.ts index e053aa40662..21c8ee1fbad 100644 --- a/packages/sdk/js/src/v2/gen/client/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/types.gen.ts @@ -1,33 +1,39 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth } from "../core/auth.gen.js" -import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" -import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" -import type { Middleware } from "./utils.gen.js" - -export type ResponseStyle = "data" | "fields" +import type { Auth } from '../core/auth.gen.js'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen.js'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen.js'; +import type { Middleware } from './utils.gen.js'; + +export type ResponseStyle = 'data' | 'fields'; export interface Config - extends Omit, + extends Omit, CoreConfig { /** * Base URL for all requests made by this client. */ - baseUrl?: T["baseUrl"] + baseUrl?: T['baseUrl']; /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Please don't use the Fetch client for Next.js applications. The `next` * options won't have any effect. * * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. */ - next?: never + next?: never; /** * Return the response data parsed in a specified format. By default, `auto` * will infer the appropriate method from the `Content-Type` response header. @@ -36,140 +42,170 @@ export interface Config * * @default 'auto' */ - parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; /** * Should we return only data or multiple fields (data, error, response, etc.)? * * @default 'fields' */ - responseStyle?: ResponseStyle + responseStyle?: ResponseStyle; /** * Throw an error instead of returning it in the response? * * @default false */ - throwOnError?: T["throwOnError"] + throwOnError?: T['throwOnError']; } export interface RequestOptions< TData = unknown, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, > extends Config<{ - responseStyle: TResponseStyle - throwOnError: ThrowOnError + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; }>, Pick< ServerSentEventsOptions, - "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' > { /** * Any body that you want to add to your request. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} */ - body?: unknown - path?: Record - query?: Record + body?: unknown; + path?: Record; + query?: Record; /** * Security mechanism(s) to use for the request. */ - security?: ReadonlyArray - url: Url + security?: ReadonlyArray; + url: Url; } export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, > extends RequestOptions { - serializedBody?: string + serializedBody?: string; } export type RequestResult< TData = unknown, TError = unknown, ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', > = ThrowOnError extends true ? Promise< - TResponseStyle extends "data" + TResponseStyle extends 'data' ? TData extends Record ? TData[keyof TData] : TData : { - data: TData extends Record ? TData[keyof TData] : TData - request: Request - response: Response + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; } > : Promise< - TResponseStyle extends "data" - ? (TData extends Record ? TData[keyof TData] : TData) | undefined + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined : ( | { - data: TData extends Record ? TData[keyof TData] : TData - error: undefined + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; } | { - data: undefined - error: TError extends Record ? TError[keyof TError] : TError + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; } ) & { - request: Request - response: Response + request: Request; + response: Response; } - > + >; export interface ClientOptions { - baseUrl?: string - responseStyle?: ResponseStyle - throwOnError?: boolean + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; } type MethodFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => RequestResult + options: Omit, 'method'>, +) => RequestResult; type SseFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => Promise> + options: Omit, 'method'>, +) => Promise>; type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method"> & - Pick>, "method">, -) => RequestResult + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; type BuildUrlFn = < TData extends { - body?: unknown - path?: Record - query?: Record - url: string + body?: unknown; + path?: Record; + query?: Record; + url: string; }, >( options: TData & Options, -) => string - -export type Client = CoreClient & { - interceptors: Middleware -} +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; /** * The `createClientConfig()` function will be called on client initialization @@ -181,22 +217,25 @@ export type Client = CoreClient */ export type CreateClientConfig = ( override?: Config, -) => Config & T> +) => Config & T>; export interface TDataShape { - body?: unknown - headers?: unknown - path?: unknown - query?: unknown - url: string + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; } -type OmitKeys = Pick> +type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown, - TResponseStyle extends ResponseStyle = "fields", -> = OmitKeys, "body" | "path" | "query" | "url"> & - ([TData] extends [never] ? unknown : Omit) + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/packages/sdk/js/src/v2/gen/client/utils.gen.ts b/packages/sdk/js/src/v2/gen/client/utils.gen.ts index 3b1dfb78718..f163053b0ae 100644 --- a/packages/sdk/js/src/v2/gen/client/utils.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/utils.gen.ts @@ -1,289 +1,332 @@ // This file is auto-generated by @hey-api/openapi-ts -import { getAuthToken } from "../core/auth.gen.js" -import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" -import { jsonBodySerializer } from "../core/bodySerializer.gen.js" -import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" -import { getUrl } from "../core/utils.gen.js" -import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" - -export const createQuerySerializer = ({ parameters = {}, ...args }: QuerySerializerOptions = {}) => { +import { getAuthToken } from '../core/auth.gen.js'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +import { jsonBodySerializer } from '../core/bodySerializer.gen.js'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen.js'; +import { getUrl } from '../core/utils.gen.js'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen.js'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { - const search: string[] = [] - if (queryParams && typeof queryParams === "object") { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { - const value = queryParams[name] + const value = queryParams[name]; if (value === undefined || value === null) { - continue + continue; } - const options = parameters[name] || args + const options = parameters[name] || args; if (Array.isArray(value)) { const serializedArray = serializeArrayParam({ allowReserved: options.allowReserved, explode: true, name, - style: "form", + style: 'form', value, ...options.array, - }) - if (serializedArray) search.push(serializedArray) - } else if (typeof value === "object") { + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { const serializedObject = serializeObjectParam({ allowReserved: options.allowReserved, explode: true, name, - style: "deepObject", + style: 'deepObject', value: value as Record, ...options.object, - }) - if (serializedObject) search.push(serializedObject) + }); + if (serializedObject) search.push(serializedObject); } else { const serializedPrimitive = serializePrimitiveParam({ allowReserved: options.allowReserved, name, value: value as string, - }) - if (serializedPrimitive) search.push(serializedPrimitive) + }); + if (serializedPrimitive) search.push(serializedPrimitive); } } } - return search.join("&") - } - return querySerializer -} + return search.join('&'); + }; + return querySerializer; +}; /** * Infers parseAs value from provided Content-Type header. */ -export const getParseAs = (contentType: string | null): Exclude => { +export const getParseAs = ( + contentType: string | null, +): Exclude => { if (!contentType) { // If no Content-Type header is provided, the best we can do is return the raw response body, // which is effectively the same as the 'stream' option. - return "stream" + return 'stream'; } - const cleanContent = contentType.split(";")[0]?.trim() + const cleanContent = contentType.split(';')[0]?.trim(); if (!cleanContent) { - return + return; } - if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { - return "json" + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; } - if (cleanContent === "multipart/form-data") { - return "formData" + if (cleanContent === 'multipart/form-data') { + return 'formData'; } - if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { - return "blob" + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; } - if (cleanContent.startsWith("text/")) { - return "text" + if (cleanContent.startsWith('text/')) { + return 'text'; } - return -} + return; +}; const checkForExistence = ( - options: Pick & { - headers: Headers + options: Pick & { + headers: Headers; }, name?: string, ): boolean => { if (!name) { - return false + return false; } - if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { - return true + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; } - return false -} + return false; +}; export const setAuthParams = async ({ security, ...options -}: Pick, "security"> & - Pick & { - headers: Headers +}: Pick, 'security'> & + Pick & { + headers: Headers; }) => { for (const auth of security) { if (checkForExistence(options, auth.name)) { - continue + continue; } - const token = await getAuthToken(auth, options.auth) + const token = await getAuthToken(auth, options.auth); if (!token) { - continue + continue; } - const name = auth.name ?? "Authorization" + const name = auth.name ?? 'Authorization'; switch (auth.in) { - case "query": + case 'query': if (!options.query) { - options.query = {} + options.query = {}; } - options.query[name] = token - break - case "cookie": - options.headers.append("Cookie", `${name}=${token}`) - break - case "header": + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': default: - options.headers.set(name, token) - break + options.headers.set(name, token); + break; } } -} +}; -export const buildUrl: Client["buildUrl"] = (options) => +export const buildUrl: Client['buildUrl'] = (options) => getUrl({ baseUrl: options.baseUrl as string, path: options.path, query: options.query, querySerializer: - typeof options.querySerializer === "function" + typeof options.querySerializer === 'function' ? options.querySerializer : createQuerySerializer(options.querySerializer), url: options.url, - }) + }); export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b } - if (config.baseUrl?.endsWith("/")) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); } - config.headers = mergeHeaders(a.headers, b.headers) - return config -} + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = [] + const entries: Array<[string, string]> = []; headers.forEach((value, key) => { - entries.push([key, value]) - }) - return entries -} - -export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { - const mergedHeaders = new Headers() + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); for (const header of headers) { if (!header) { - continue + continue; } - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header) + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); for (const [key, value] of iterator) { if (value === null) { - mergedHeaders.delete(key) + mergedHeaders.delete(key); } else if (Array.isArray(value)) { for (const v of value) { - mergedHeaders.append(key, v as string) + mergedHeaders.append(key, v as string); } } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); } } } - return mergedHeaders -} + return mergedHeaders; +}; type ErrInterceptor = ( error: Err, response: Res, request: Req, options: Options, -) => Err | Promise +) => Err | Promise; -type ReqInterceptor = (request: Req, options: Options) => Req | Promise +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; -type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; class Interceptors { - fns: Array = [] + fns: Array = []; clear(): void { - this.fns = [] + this.fns = []; } eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id) + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = null + this.fns[index] = null; } } exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id) - return Boolean(this.fns[index]) + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); } getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === "number") { - return this.fns[id] ? id : -1 + if (typeof id === 'number') { + return this.fns[id] ? id : -1; } - return this.fns.indexOf(id) + return this.fns.indexOf(id); } - update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id) + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = fn - return id + this.fns[index] = fn; + return id; } - return false + return false; } use(fn: Interceptor): number { - this.fns.push(fn) - return this.fns.length - 1 + this.fns.push(fn); + return this.fns.length - 1; } } export interface Middleware { - error: Interceptors> - request: Interceptors> - response: Interceptors> + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; } -export const createInterceptors = (): Middleware => ({ +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ error: new Interceptors>(), request: new Interceptors>(), response: new Interceptors>(), -}) +}); const defaultQuerySerializer = createQuerySerializer({ allowReserved: false, array: { explode: true, - style: "form", + style: 'form', }, object: { explode: true, - style: "deepObject", + style: 'deepObject', }, -}) +}); const defaultHeaders = { - "Content-Type": "application/json", -} + 'Content-Type': 'application/json', +}; export const createConfig = ( override: Config & T> = {}, ): Config & T> => ({ ...jsonBodySerializer, headers: defaultHeaders, - parseAs: "auto", + parseAs: 'auto', querySerializer: defaultQuerySerializer, ...override, -}) +}); diff --git a/packages/sdk/js/src/v2/gen/core/auth.gen.ts b/packages/sdk/js/src/v2/gen/core/auth.gen.ts index bc7b230f447..f8a73266f93 100644 --- a/packages/sdk/js/src/v2/gen/core/auth.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/auth.gen.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type AuthToken = string | undefined +export type AuthToken = string | undefined; export interface Auth { /** @@ -8,34 +8,35 @@ export interface Auth { * * @default 'header' */ - in?: "header" | "query" | "cookie" + in?: 'header' | 'query' | 'cookie'; /** * Header or query parameter name. * * @default 'Authorization' */ - name?: string - scheme?: "basic" | "bearer" - type: "apiKey" | "http" + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; } export const getAuthToken = async ( auth: Auth, callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, ): Promise => { - const token = typeof callback === "function" ? await callback(auth) : callback + const token = + typeof callback === 'function' ? await callback(auth) : callback; if (!token) { - return + return; } - if (auth.scheme === "bearer") { - return `Bearer ${token}` + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; } - if (auth.scheme === "basic") { - return `Basic ${btoa(token)}` + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; } - return token -} + return token; +}; diff --git a/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts index 9678fb08ec6..886b401aefe 100644 --- a/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/bodySerializer.gen.ts @@ -1,82 +1,100 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen.js'; -export type QuerySerializer = (query: Record) => string +export type QuerySerializer = (query: Record) => string; -export type BodySerializer = (body: any) => any +export type BodySerializer = (body: any) => any; type QuerySerializerOptionsObject = { - allowReserved?: boolean - array?: Partial> - object?: Partial> -} + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; export type QuerySerializerOptions = QuerySerializerOptionsObject & { /** * Per-parameter serialization overrides. When provided, these settings * override the global array/object settings for specific parameter names. */ - parameters?: Record -} + parameters?: Record; +}; -const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === "string" || value instanceof Blob) { - data.append(key, value) +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); } else if (value instanceof Date) { - data.append(key, value.toISOString()) + data.append(key, value.toISOString()); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; -const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === "string") { - data.append(key, value) +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; export const formDataBodySerializer = { - bodySerializer: | Array>>(body: T): FormData => { - const data = new FormData() + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)) + value.forEach((v) => serializeFormDataPair(data, key, v)); } else { - serializeFormDataPair(data, key, value) + serializeFormDataPair(data, key, value); } - }) + }); - return data + return data; }, -} +}; export const jsonBodySerializer = { bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), -} + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams() + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); } else { - serializeUrlSearchParamsPair(data, key, value) + serializeUrlSearchParamsPair(data, key, value); } - }) + }); - return data.toString() + return data.toString(); }, -} +}; diff --git a/packages/sdk/js/src/v2/gen/core/params.gen.ts b/packages/sdk/js/src/v2/gen/core/params.gen.ts index 6e9d0b9add4..602715c46cc 100644 --- a/packages/sdk/js/src/v2/gen/core/params.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/params.gen.ts @@ -1,160 +1,167 @@ // This file is auto-generated by @hey-api/openapi-ts -type Slot = "body" | "headers" | "path" | "query" +type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { - in: Exclude + in: Exclude; /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If omitted, we use the same value as `key`. */ - map?: string + map?: string; } | { - in: Extract + in: Extract; /** * Key isn't required for bodies. */ - key?: string - map?: string + key?: string; + map?: string; } | { /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If `in` is omitted, `map` aliases `key` to the transport layer. */ - map: Slot - } + map: Slot; + }; export interface Fields { - allowExtra?: Partial> - args?: ReadonlyArray + allowExtra?: Partial>; + args?: ReadonlyArray; } -export type FieldsConfig = ReadonlyArray +export type FieldsConfig = ReadonlyArray; const extraPrefixesMap: Record = { - $body_: "body", - $headers_: "headers", - $path_: "path", - $query_: "query", -} -const extraPrefixes = Object.entries(extraPrefixesMap) + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< string, | { - in: Slot - map?: string + in: Slot; + map?: string; } | { - in?: never - map: Slot + in?: never; + map: Slot; } -> +>; const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { if (!map) { - map = new Map() + map = new Map(); } for (const config of fields) { - if ("in" in config) { + if ('in' in config) { if (config.key) { map.set(config.key, { in: config.in, map: config.map, - }) + }); } - } else if ("key" in config) { + } else if ('key' in config) { map.set(config.key, { map: config.map, - }) + }); } else if (config.args) { - buildKeyMap(config.args, map) + buildKeyMap(config.args, map); } } - return map -} + return map; +}; interface Params { - body: unknown - headers: Record - path: Record - query: Record + body: unknown; + headers: Record; + path: Record; + query: Record; } const stripEmptySlots = (params: Params) => { for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === "object" && !Object.keys(value).length) { - delete params[slot as Slot] + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; } } -} +}; -export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { const params: Params = { body: {}, headers: {}, path: {}, query: {}, - } + }; - const map = buildKeyMap(fields) + const map = buildKeyMap(fields); - let config: FieldsConfig[number] | undefined + let config: FieldsConfig[number] | undefined; for (const [index, arg] of args.entries()) { if (fields[index]) { - config = fields[index] + config = fields[index]; } if (!config) { - continue + continue; } - if ("in" in config) { + if ('in' in config) { if (config.key) { - const field = map.get(config.key)! - const name = field.map || config.key + const field = map.get(config.key)!; + const name = field.map || config.key; if (field.in) { - ;(params[field.in] as Record)[name] = arg + (params[field.in] as Record)[name] = arg; } } else { - params.body = arg + params.body = arg; } } else { for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key) + const field = map.get(key); if (field) { if (field.in) { - const name = field.map || key - ;(params[field.in] as Record)[name] = value + const name = field.map || key; + (params[field.in] as Record)[name] = value; } else { - params[field.map] = value + params[field.map] = value; } } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); if (extra) { - const [prefix, slot] = extra - ;(params[slot] as Record)[key.slice(prefix.length)] = value - } else if ("allowExtra" in config && config.allowExtra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else if ('allowExtra' in config && config.allowExtra) { for (const [slot, allowed] of Object.entries(config.allowExtra)) { if (allowed) { - ;(params[slot as Slot] as Record)[key] = value - break + (params[slot as Slot] as Record)[key] = value; + break; } } } @@ -163,7 +170,7 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo } } - stripEmptySlots(params) + stripEmptySlots(params); - return params -} + return params; +}; diff --git a/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts index 96be3bc5a39..8d999310474 100644 --- a/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/pathSerializer.gen.ts @@ -1,68 +1,70 @@ // This file is auto-generated by @hey-api/openapi-ts -interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} interface SerializePrimitiveOptions { - allowReserved?: boolean - name: string + allowReserved?: boolean; + name: string; } export interface SerializerOptions { /** * @default true */ - explode: boolean - style: T + explode: boolean; + style: T; } -export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle -type MatrixStyle = "label" | "matrix" | "simple" -export type ObjectStyle = "form" | "deepObject" -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string + value: string; } export const separatorArrayExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "form": - return "," - case "pipeDelimited": - return "|" - case "spaceDelimited": - return "%20" + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; default: - return "," + return ','; } -} +}; export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const serializeArrayParam = ({ allowReserved, @@ -71,54 +73,60 @@ export const serializeArrayParam = ({ style, value, }: SerializeOptions & { - value: unknown[] + value: unknown[]; }) => { if (!explode) { - const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( - separatorArrayNoExplode(style), - ) + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); switch (style) { - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` - case "simple": - return joinedValues + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; default: - return `${name}=${joinedValues}` + return `${name}=${joinedValues}`; } } - const separator = separatorArrayExplode(style) + const separator = separatorArrayExplode(style); const joinedValues = value .map((v) => { - if (style === "label" || style === "simple") { - return allowReserved ? v : encodeURIComponent(v as string) + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); } return serializePrimitiveParam({ allowReserved, name, value: v as string, - }) + }); }) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; -export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { if (value === undefined || value === null) { - return "" + return ''; } - if (typeof value === "object") { + if (typeof value === 'object') { throw new Error( - "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", - ) + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); } - return `${name}=${allowReserved ? value : encodeURIComponent(value)}` -} + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; export const serializeObjectParam = ({ allowReserved, @@ -128,40 +136,46 @@ export const serializeObjectParam = ({ value, valueOnly, }: SerializeOptions & { - value: Record | Date - valueOnly?: boolean + value: Record | Date; + valueOnly?: boolean; }) => { if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; } - if (style !== "deepObject" && !explode) { - let values: string[] = [] + if (style !== 'deepObject' && !explode) { + let values: string[] = []; Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] - }) - const joinedValues = values.join(",") + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); switch (style) { - case "form": - return `${name}=${joinedValues}` - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; default: - return joinedValues + return joinedValues; } } - const separator = separatorObjectExplode(style) + const separator = separatorObjectExplode(style); const joinedValues = Object.entries(value) .map(([key, v]) => serializePrimitiveParam({ allowReserved, - name: style === "deepObject" ? `${name}[${key}]` : key, + name: style === 'deepObject' ? `${name}[${key}]` : key, value: v as string, }), ) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts b/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts index 320204aef10..d3bb68396e9 100644 --- a/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/queryKeySerializer.gen.ts @@ -3,109 +3,134 @@ /** * JSON-friendly union that mirrors what Pinia Colada can hash. */ -export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } - return value -} + return value; +}; /** * Safely stringifies a value and parses it back into a JsonValue. */ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { try { - const json = JSON.stringify(input, queryKeyJsonReplacer) + const json = JSON.stringify(input, queryKeyJsonReplacer); if (json === undefined) { - return undefined + return undefined; } - return JSON.parse(json) as JsonValue + return JSON.parse(json) as JsonValue; } catch { - return undefined + return undefined; } -} +}; /** * Detects plain objects (including objects with a null prototype). */ const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== "object") { - return false + if (value === null || typeof value !== 'object') { + return false; } - const prototype = Object.getPrototypeOf(value as object) - return prototype === Object.prototype || prototype === null -} + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; /** * Turns URLSearchParams into a sorted JSON object for deterministic keys. */ const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) - const result: Record = {} + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record = {}; for (const [key, value] of entries) { - const existing = result[key] + const existing = result[key]; if (existing === undefined) { - result[key] = value - continue + result[key] = value; + continue; } if (Array.isArray(existing)) { - ;(existing as string[]).push(value) + (existing as string[]).push(value); } else { - result[key] = [existing, value] + result[key] = [existing, value]; } } - return result -} + return result; +}; /** * Normalizes any accepted value into a JSON-friendly shape for query keys. */ -export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { if (value === null) { - return null + return null; } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return value + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; } - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } if (Array.isArray(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { - return serializeSearchParams(value) + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); } if (isPlainObject(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - return undefined -} + return undefined; +}; diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts index 056a8125932..4dd6ae93299 100644 --- a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -1,20 +1,23 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Config } from "./types.gen.js" +import type { Config } from './types.gen.js'; -export type ServerSentEventsOptions = Omit & - Pick & { +export type ServerSentEventsOptions = Omit< + RequestInit, + 'method' +> & + Pick & { /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Implementing clients can call request interceptors inside this hook. */ - onRequest?: (url: string, init: RequestInit) => Promise + onRequest?: (url: string, init: RequestInit) => Promise; /** * Callback invoked when a network or parsing error occurs during streaming. * @@ -22,7 +25,7 @@ export type ServerSentEventsOptions = Omit void + onSseError?: (error: unknown) => void; /** * Callback invoked when an event is streamed from the server. * @@ -31,8 +34,8 @@ export type ServerSentEventsOptions = Omit) => void - serializedBody?: RequestInit["body"] + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; /** * Default retry delay in milliseconds. * @@ -40,11 +43,11 @@ export type ServerSentEventsOptions = Omit = Omit Promise - url: string - } + sseSleepFn?: (ms: number) => Promise; + url: string; + }; export interface StreamEvent { - data: TData - event?: string - id?: string - retry?: number + data: TData; + event?: string; + id?: string; + retry?: number; } -export type ServerSentEventsResult = { - stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> -} +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; export const createSseClient = ({ onRequest, @@ -88,115 +99,125 @@ export const createSseClient = ({ url, ...options }: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined + let lastEventId: string | undefined; - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000 - let attempt = 0 - const signal = options.signal ?? new AbortController().signal + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; while (true) { - if (signal.aborted) break + if (signal.aborted) break; - attempt++ + attempt++; const headers = options.headers instanceof Headers ? options.headers - : new Headers(options.headers as Record | undefined) + : new Headers(options.headers as Record | undefined); if (lastEventId !== undefined) { - headers.set("Last-Event-ID", lastEventId) + headers.set('Last-Event-ID', lastEventId); } try { const requestInit: RequestInit = { - redirect: "follow", + redirect: 'follow', ...options, body: options.serializedBody, headers, signal, - } - let request = new Request(url, requestInit) + }; + let request = new Request(url, requestInit); if (onRequest) { - request = await onRequest(url, requestInit) + request = await onRequest(url, requestInit); } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch - const response = await _fetch(request) + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); - if (!response.body) throw new Error("No body in SSE response") + if (!response.body) throw new Error('No body in SSE response'); - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); - let buffer = "" + let buffer = ''; const abortHandler = () => { try { - reader.cancel() + reader.cancel(); } catch { // noop } - } + }; - signal.addEventListener("abort", abortHandler) + signal.addEventListener('abort', abortHandler); try { while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += value + const { done, value } = await reader.read(); + if (done) break; + buffer += value; // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const chunks = buffer.split("\n\n") - buffer = chunks.pop() ?? "" + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; for (const chunk of chunks) { - const lines = chunk.split("\n") - const dataLines: Array = [] - let eventName: string | undefined + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; for (const line of lines) { - if (line.startsWith("data:")) { - dataLines.push(line.replace(/^data:\s*/, "")) - } else if (line.startsWith("event:")) { - eventName = line.replace(/^event:\s*/, "") - } else if (line.startsWith("id:")) { - lastEventId = line.replace(/^id:\s*/, "") - } else if (line.startsWith("retry:")) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); if (!Number.isNaN(parsed)) { - retryDelay = parsed + retryDelay = parsed; } } } - let data: unknown - let parsedJson = false + let data: unknown; + let parsedJson = false; if (dataLines.length) { - const rawData = dataLines.join("\n") + const rawData = dataLines.join('\n'); try { - data = JSON.parse(rawData) - parsedJson = true + data = JSON.parse(rawData); + parsedJson = true; } catch { - data = rawData + data = rawData; } } if (parsedJson) { if (responseValidator) { - await responseValidator(data) + await responseValidator(data); } if (responseTransformer) { - data = await responseTransformer(data) + data = await responseTransformer(data); } } @@ -205,35 +226,41 @@ export const createSseClient = ({ event: eventName, id: lastEventId, retry: retryDelay, - }) + }); if (dataLines.length) { - yield data as any + yield data as any; } } } } finally { - signal.removeEventListener("abort", abortHandler) - reader.releaseLock() + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); } - break // exit loop on normal completion + break; // exit loop on normal completion } catch (error) { // connection failed or aborted; retry after delay - onSseError?.(error) + onSseError?.(error); - if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break // stop after firing error + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error } // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) - await sleep(backoff) + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); } } - } + }; - const stream = createStream() + const stream = createStream(); - return { stream } -} + return { stream }; +}; diff --git a/packages/sdk/js/src/v2/gen/core/types.gen.ts b/packages/sdk/js/src/v2/gen/core/types.gen.ts index bfa77b8acd2..cc8a9e60fae 100644 --- a/packages/sdk/js/src/v2/gen/core/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/types.gen.ts @@ -1,33 +1,54 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth, AuthToken } from "./auth.gen.js" -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" +import type { Auth, AuthToken } from './auth.gen.js'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen.js'; -export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace" +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; -export type Client = { +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { /** * Returns the final request URL. */ - buildUrl: BuildUrlFn - getConfig: () => Config - request: RequestFn - setConfig: (config: Config) => Config + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; } & { - [K in HttpMethod]: MethodFn -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }) + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** * Auth token or a function returning auth token. The resolved value will be * added to the request payload as defined by its `security` array. */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; /** * A function for serializing request body parameter. By default, * {@link JSON.stringify()} will be used. */ - bodySerializer?: BodySerializer | null + bodySerializer?: BodySerializer | null; /** * An object containing any HTTP headers that you want to pre-populate your * `Headers` object with. @@ -35,14 +56,23 @@ export interface Config { * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} */ headers?: - | RequestInit["headers"] - | Record + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; /** * The request method. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: Uppercase + method?: Uppercase; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -53,24 +83,24 @@ export interface Config { * * {@link https://swagger.io/docs/specification/serialization/#query View examples} */ - querySerializer?: QuerySerializer | QuerySerializerOptions + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function validating request data. This is useful if you want to ensure * the request conforms to the desired shape, so it can be safely sent to * the server. */ - requestValidator?: (data: unknown) => Promise + requestValidator?: (data: unknown) => Promise; /** * A function transforming response data before it's returned. This is useful * for post-processing data, e.g. converting ISO strings into Date objects. */ - responseTransformer?: (data: unknown) => Promise + responseTransformer?: (data: unknown) => Promise; /** * A function validating response data. This is useful if you want to ensure * the response conforms to the desired shape, so it can be safely passed to * the transformers and returned to the user. */ - responseValidator?: (data: unknown) => Promise + responseValidator?: (data: unknown) => Promise; } type IsExactlyNeverOrNeverUndefined = [T] extends [never] @@ -79,8 +109,10 @@ type IsExactlyNeverOrNeverUndefined = [T] extends [never] ? [undefined] extends [T] ? false : true - : false + : false; export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] -} + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/packages/sdk/js/src/v2/gen/core/utils.gen.ts b/packages/sdk/js/src/v2/gen/core/utils.gen.ts index 8a45f72698a..3029f7b3cc0 100644 --- a/packages/sdk/js/src/v2/gen/core/utils.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/utils.gen.ts @@ -1,54 +1,57 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js" +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen.js'; import { type ArraySeparatorStyle, serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from "./pathSerializer.gen.js" +} from './pathSerializer.gen.js'; export interface PathSerializer { - path: Record - url: string + path: Record; + url: string; } -export const PATH_PARAM_RE = /\{[^{}]+\}/g +export const PATH_PARAM_RE = /\{[^{}]+\}/g; export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url - const matches = _url.match(PATH_PARAM_RE) + let url = _url; + const matches = _url.match(PATH_PARAM_RE); if (matches) { for (const match of matches) { - let explode = false - let name = match.substring(1, match.length - 1) - let style: ArraySeparatorStyle = "simple" + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; - if (name.endsWith("*")) { - explode = true - name = name.substring(0, name.length - 1) + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); } - if (name.startsWith(".")) { - name = name.substring(1) - style = "label" - } else if (name.startsWith(";")) { - name = name.substring(1) - style = "matrix" + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; } - const value = path[name] + const value = path[name]; if (value === undefined || value === null) { - continue + continue; } if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })) - continue + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; } - if (typeof value === "object") { + if (typeof value === 'object') { url = url.replace( match, serializeObjectParam({ @@ -58,27 +61,29 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { value: value as Record, valueOnly: true, }), - ) - continue + ); + continue; } - if (style === "matrix") { + if (style === 'matrix') { url = url.replace( match, `;${serializePrimitiveParam({ name, value: value as string, })}`, - ) - continue + ); + continue; } - const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) - url = url.replace(match, replaceValue) + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); } } - return url -} + return url; +}; export const getUrl = ({ baseUrl, @@ -87,51 +92,52 @@ export const getUrl = ({ querySerializer, url: _url, }: { - baseUrl?: string - path?: Record - query?: Record - querySerializer: QuerySerializer - url: string + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; }) => { - const pathUrl = _url.startsWith("/") ? _url : `/${_url}` - let url = (baseUrl ?? "") + pathUrl + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; if (path) { - url = defaultPathSerializer({ path, url }) + url = defaultPathSerializer({ path, url }); } - let search = query ? querySerializer(query) : "" - if (search.startsWith("?")) { - search = search.substring(1) + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); } if (search) { - url += `?${search}` + url += `?${search}`; } - return url -} + return url; +}; export function getValidRequestBody(options: { - body?: unknown - bodySerializer?: BodySerializer | null - serializedBody?: unknown + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; }) { - const hasBody = options.body !== undefined - const isSerializedBody = hasBody && options.bodySerializer + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; if (isSerializedBody) { - if ("serializedBody" in options) { - const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "" + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; - return hasSerializedBody ? options.serializedBody : null + return hasSerializedBody ? options.serializedBody : null; } // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== "" ? options.body : null + return options.body !== '' ? options.body : null; } // plain/text body if (hasBody) { - return options.body + return options.body; } // no body was provided - return undefined + return undefined; }