refactor(server): canonicalize service API (#31049)
This commit is contained in:
parent
53ff1b57c9
commit
fe0c4f8c74
388 changed files with 7075 additions and 4064 deletions
19
packages/tui/src/util/collapse-tool-output.ts
Normal file
19
packages/tui/src/util/collapse-tool-output.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
|
||||
const lines = output.split("\n")
|
||||
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
|
||||
return { output, overflow: false }
|
||||
}
|
||||
|
||||
const preview = lines.slice(0, maxLines).join("\n")
|
||||
if (Array.from(preview).length > maxChars) {
|
||||
return {
|
||||
output:
|
||||
Array.from(preview)
|
||||
.slice(0, Math.max(0, maxChars - 1))
|
||||
.join("") + "…",
|
||||
overflow: true,
|
||||
}
|
||||
}
|
||||
|
||||
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
||||
}
|
||||
181
packages/tui/src/util/error.ts
Normal file
181
packages/tui/src/util/error.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { isRecord } from "./record"
|
||||
|
||||
type ConfigIssue = { message: string; path: string[] }
|
||||
|
||||
export function cliErrorMessage(input: unknown): string | undefined {
|
||||
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
|
||||
const formatted = cliErrorMessage(input.cause.body)
|
||||
if (formatted) return formatted
|
||||
}
|
||||
|
||||
if (tagged(input, "CliError")) {
|
||||
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
|
||||
return field(input, "message") ?? ""
|
||||
}
|
||||
if (tagged(input, "AccountServiceError") || tagged(input, "AccountTransportError")) {
|
||||
return field(input, "message") ?? ""
|
||||
}
|
||||
|
||||
const model = configData(input, "ProviderModelNotFoundError")
|
||||
if (model) {
|
||||
const suggestions = Array.isArray(model.suggestions)
|
||||
? model.suggestions.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
return [
|
||||
`Model not found: ${field(model, "providerID")}/${field(model, "modelID")}`,
|
||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
"Try: `opencode models` to list available models",
|
||||
"Or check your config (opencode.json) provider/model names",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const provider = configData(input, "ProviderInitError")
|
||||
if (provider) return `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.`
|
||||
|
||||
const json = configData(input, "ConfigJsonError")
|
||||
if (json) {
|
||||
const message = field(json, "message")
|
||||
return `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
|
||||
}
|
||||
|
||||
const directory = configData(input, "ConfigDirectoryTypoError")
|
||||
if (directory) {
|
||||
return `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.`
|
||||
}
|
||||
|
||||
const frontmatter = configData(input, "ConfigFrontmatterError")
|
||||
if (frontmatter) return field(frontmatter, "message") ?? ""
|
||||
|
||||
const invalid = configData(input, "ConfigInvalidError")
|
||||
if (invalid) {
|
||||
const path = field(invalid, "path")
|
||||
const message = field(invalid, "message")
|
||||
const issues = Array.isArray(invalid.issues)
|
||||
? invalid.issues.filter((issue): issue is ConfigIssue => {
|
||||
return (
|
||||
isRecord(issue) &&
|
||||
typeof issue.message === "string" &&
|
||||
Array.isArray(issue.path) &&
|
||||
issue.path.every((item) => typeof item === "string")
|
||||
)
|
||||
})
|
||||
: []
|
||||
return [
|
||||
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) return ""
|
||||
if (isRecord(input) && named(input, "MCPFailed")) {
|
||||
const name = isRecord(input.data) ? field(input.data, "name") : undefined
|
||||
return `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function tagged(input: unknown, tag: string): input is Record<string, unknown> {
|
||||
return isRecord(input) && input._tag === tag
|
||||
}
|
||||
|
||||
function named(input: unknown, name: string) {
|
||||
return isRecord(input) && (input.name === name || input._tag === name)
|
||||
}
|
||||
|
||||
function configData(input: unknown, tag: string) {
|
||||
if (!isRecord(input)) return undefined
|
||||
if (input.name === tag && isRecord(input.data)) return input.data
|
||||
if (input._tag === tag) return input
|
||||
return undefined
|
||||
}
|
||||
|
||||
function field(input: Record<string, unknown>, key: string) {
|
||||
return typeof input[key] === "string" ? input[key] : undefined
|
||||
}
|
||||
|
||||
export function errorFormat(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.stack ?? `${error.name}: ${error.message}`
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
try {
|
||||
const json = JSON.stringify(error, null, 2)
|
||||
// Plain objects whose own properties are all non-enumerable (or empty)
|
||||
// serialize to "{}", which prints as a useless bare `{}` on stderr.
|
||||
// Fall back to a custom toString first, then to ctor name + own prop names.
|
||||
if (json === "{}") {
|
||||
const str = String(error)
|
||||
if (str && str !== "[object Object]") return str
|
||||
const ctor = error.constructor?.name
|
||||
const prefix = ctor && ctor !== "Object" ? ctor : "Error"
|
||||
const names = Object.getOwnPropertyNames(error)
|
||||
return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
|
||||
}
|
||||
return json
|
||||
} catch {
|
||||
return "Unexpected error (unserializable)"
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
if (error.message) return error.message
|
||||
if (error.name) return error.name
|
||||
}
|
||||
|
||||
if (isRecord(error) && typeof error.message === "string" && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) {
|
||||
return error.data.message
|
||||
}
|
||||
|
||||
const text = String(error)
|
||||
if (text && text !== "[object Object]") return text
|
||||
|
||||
const formatted = errorFormat(error)
|
||||
if (formatted) return formatted
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
export function errorData(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
type: error.name,
|
||||
message: errorMessage(error),
|
||||
stack: error.stack,
|
||||
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRecord(error)) {
|
||||
return {
|
||||
type: typeof error,
|
||||
message: errorMessage(error),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
|
||||
const value = error[key]
|
||||
if (value === undefined) return acc
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}
|
||||
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
|
||||
acc[key] = value instanceof Error ? value.message : String(value)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (typeof data.message !== "string") data.message = errorMessage(error)
|
||||
if (typeof data.type !== "string") data.type = error.constructor?.name
|
||||
data.formatted = errorFormat(error)
|
||||
return data
|
||||
}
|
||||
130
packages/tui/src/util/filetype.ts
Normal file
130
packages/tui/src/util/filetype.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import path from "node:path"
|
||||
|
||||
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".abap": "abap",
|
||||
".bat": "bat",
|
||||
".bib": "bibtex",
|
||||
".bibtex": "bibtex",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".edn": "clojure",
|
||||
".coffee": "coffeescript",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".cc": "cpp",
|
||||
".c++": "cpp",
|
||||
".cs": "csharp",
|
||||
".csx": "csharp",
|
||||
".css": "css",
|
||||
".d": "d",
|
||||
".pas": "pascal",
|
||||
".pascal": "pascal",
|
||||
".diff": "diff",
|
||||
".patch": "diff",
|
||||
".dart": "dart",
|
||||
".dockerfile": "dockerfile",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".ets": "typescript",
|
||||
".hrl": "erlang",
|
||||
".fs": "fsharp",
|
||||
".fsi": "fsharp",
|
||||
".fsx": "fsharp",
|
||||
".fsscript": "fsharp",
|
||||
".gitcommit": "git-commit",
|
||||
".gitrebase": "git-rebase",
|
||||
".go": "go",
|
||||
".groovy": "groovy",
|
||||
".gleam": "gleam",
|
||||
".hbs": "handlebars",
|
||||
".handlebars": "handlebars",
|
||||
".hs": "haskell",
|
||||
".lhs": "haskell",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".ini": "ini",
|
||||
".java": "java",
|
||||
".jl": "julia",
|
||||
".js": "javascript",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".jsx": "javascriptreact",
|
||||
".json": "json",
|
||||
".tex": "latex",
|
||||
".latex": "latex",
|
||||
".less": "less",
|
||||
".lua": "lua",
|
||||
".makefile": "makefile",
|
||||
makefile: "makefile",
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".m": "objective-c",
|
||||
".mm": "objective-cpp",
|
||||
".pl": "perl",
|
||||
".pm": "perl",
|
||||
".pm6": "perl6",
|
||||
".php": "php",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".pug": "jade",
|
||||
".jade": "jade",
|
||||
".py": "python",
|
||||
".r": "r",
|
||||
".cshtml": "razor",
|
||||
".razor": "razor",
|
||||
".rb": "ruby",
|
||||
".rake": "ruby",
|
||||
".gemspec": "ruby",
|
||||
".ru": "ruby",
|
||||
".erb": "erb",
|
||||
".html.erb": "erb",
|
||||
".js.erb": "erb",
|
||||
".css.erb": "erb",
|
||||
".json.erb": "erb",
|
||||
".rs": "rust",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".scala": "scala",
|
||||
".shader": "shaderlab",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".ksh": "shellscript",
|
||||
".sql": "sql",
|
||||
".svelte": "svelte",
|
||||
".swift": "swift",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".mtsx": "typescriptreact",
|
||||
".ctsx": "typescriptreact",
|
||||
".xml": "xml",
|
||||
".xsl": "xsl",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".vue": "vue",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
".astro": "astro",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform-vars",
|
||||
".hcl": "hcl",
|
||||
".nix": "nix",
|
||||
".typ": "typst",
|
||||
".typc": "typst",
|
||||
}
|
||||
|
||||
export function filetype(input?: string) {
|
||||
if (!input) return "none"
|
||||
const language = LANGUAGE_EXTENSIONS[path.extname(input)]
|
||||
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
|
||||
return language
|
||||
}
|
||||
20
packages/tui/src/util/format.ts
Normal file
20
packages/tui/src/util/format.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export function formatDuration(secs: number) {
|
||||
if (secs <= 0) return ""
|
||||
if (secs < 60) return `${secs}s`
|
||||
if (secs < 3600) {
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remaining = secs % 60
|
||||
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
||||
}
|
||||
if (secs < 86400) {
|
||||
const hours = Math.floor(secs / 3600)
|
||||
const remaining = Math.floor((secs % 3600) / 60)
|
||||
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
||||
}
|
||||
if (secs < 604800) {
|
||||
const days = Math.floor(secs / 86400)
|
||||
return days === 1 ? "~1 day" : `~${days} days`
|
||||
}
|
||||
const weeks = Math.floor(secs / 604800)
|
||||
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
||||
}
|
||||
25
packages/tui/src/util/layout.ts
Normal file
25
packages/tui/src/util/layout.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
|
||||
|
||||
const previousByParent = new WeakMap<
|
||||
BaseRenderable,
|
||||
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
|
||||
>()
|
||||
|
||||
export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) {
|
||||
// Run before Yoga layout so scroll geometry matches the rendered frame.
|
||||
el.onLifecyclePass = () => {
|
||||
const parent = el.parent
|
||||
if (!parent) return
|
||||
const cached = previousByParent.get(parent)
|
||||
const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId)
|
||||
const value = margin(previous.get(el))
|
||||
if (el.marginTop !== value) el.marginTop = value
|
||||
}
|
||||
}
|
||||
|
||||
function previousSiblings(parent: BaseRenderable, frameID: number) {
|
||||
const previous = new WeakMap<BaseRenderable, BaseRenderable | undefined>()
|
||||
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
|
||||
previousByParent.set(parent, { frameID, previous })
|
||||
return previous
|
||||
}
|
||||
86
packages/tui/src/util/locale.ts
Normal file
86
packages/tui/src/util/locale.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export function titlecase(str: string) {
|
||||
return str.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function time(input: number): string {
|
||||
const date = new Date(input)
|
||||
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
||||
}
|
||||
|
||||
export function datetime(input: number): string {
|
||||
const date = new Date(input)
|
||||
const localTime = time(input)
|
||||
const localDate = date.toLocaleDateString()
|
||||
return `${localTime} · ${localDate}`
|
||||
}
|
||||
|
||||
export function todayTimeOrDateTime(input: number): string {
|
||||
const date = new Date(input)
|
||||
const now = new Date()
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
||||
|
||||
if (isToday) {
|
||||
return time(input)
|
||||
} else {
|
||||
return datetime(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function number(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "K"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export function duration(input: number) {
|
||||
if (input < 1000) {
|
||||
return `${input}ms`
|
||||
}
|
||||
if (input < 60000) {
|
||||
return `${(input / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (input < 3600000) {
|
||||
const minutes = Math.floor(input / 60000)
|
||||
const seconds = Math.floor((input % 60000) / 1000)
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
if (input < 86400000) {
|
||||
const hours = Math.floor(input / 3600000)
|
||||
const minutes = Math.floor((input % 3600000) / 60000)
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
const hours = Math.floor(input / 3600000)
|
||||
const days = Math.floor((input % 3600000) / 86400000)
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
|
||||
export function truncate(str: string, len: number): string {
|
||||
if (str.length <= len) return str
|
||||
return str.slice(0, len - 1) + "…"
|
||||
}
|
||||
|
||||
export function truncateLeft(str: string, len: number): string {
|
||||
if (str.length <= len) return str
|
||||
return "…" + str.slice(-(len - 1))
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, maxLength: number = 35): string {
|
||||
if (str.length <= maxLength) return str
|
||||
|
||||
const ellipsis = "…"
|
||||
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
|
||||
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
|
||||
|
||||
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string, plural: string): string {
|
||||
const template = count === 1 ? singular : plural
|
||||
return template.replace("{}", count.toString())
|
||||
}
|
||||
|
||||
export * as Locale from "./locale"
|
||||
28
packages/tui/src/util/model.ts
Normal file
28
packages/tui/src/util/model.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function parse(value: string) {
|
||||
const [providerID, ...modelID] = value.split("/")
|
||||
return { providerID, modelID: modelID.join("/") }
|
||||
}
|
||||
|
||||
export function index(list: Provider[] | undefined) {
|
||||
return new Map((list ?? []).map((item) => [item.id, item] as const))
|
||||
}
|
||||
|
||||
export function get(list: Provider[] | ReadonlyMap<string, Provider> | undefined, providerID: string, modelID: string) {
|
||||
const provider =
|
||||
list instanceof Map
|
||||
? list.get(providerID)
|
||||
: Array.isArray(list)
|
||||
? list.find((item) => item.id === providerID)
|
||||
: undefined
|
||||
return provider?.models[modelID]
|
||||
}
|
||||
|
||||
export function name(
|
||||
list: Provider[] | ReadonlyMap<string, Provider> | undefined,
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
) {
|
||||
return get(list, providerID, modelID)?.name ?? modelID
|
||||
}
|
||||
12
packages/tui/src/util/path.ts
Normal file
12
packages/tui/src/util/path.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { realpathSync } from "node:fs"
|
||||
import { win32 } from "node:path"
|
||||
|
||||
export function normalizePath(input: string, platform: string) {
|
||||
if (platform !== "win32") return input
|
||||
const resolved = win32.normalize(win32.resolve(input.replaceAll("/", "\\")))
|
||||
try {
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
33
packages/tui/src/util/persistence.ts
Normal file
33
packages/tui/src/util/persistence.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import path from "path"
|
||||
import { appendFile, mkdir, rename, rm } from "fs/promises"
|
||||
|
||||
export function readText(filePath: string) {
|
||||
return Bun.file(filePath).text()
|
||||
}
|
||||
|
||||
export function readJson<T>(filePath: string) {
|
||||
return Bun.file(filePath).json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function writeText(filePath: string, content: string) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await Bun.write(filePath, content)
|
||||
}
|
||||
|
||||
export async function appendText(filePath: string, content: string) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await appendFile(filePath, content)
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath: string, value: unknown) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`
|
||||
await Bun.write(temporary, JSON.stringify(value)).catch(async (error) => {
|
||||
await rm(temporary, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
})
|
||||
await rename(temporary, filePath).catch(async (error) => {
|
||||
await rm(temporary, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
38
packages/tui/src/util/presentation.ts
Normal file
38
packages/tui/src/util/presentation.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const logo = {
|
||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
const reset = "\x1b[0m"
|
||||
const bold = "\x1b[1m"
|
||||
const dim = "\x1b[90m"
|
||||
|
||||
function wordmark(pad = "") {
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) =>
|
||||
[...line]
|
||||
.map((char) => {
|
||||
if (char === "_") return `${bg} ${reset}`
|
||||
if (char === "^") return `${fg}${bg}▀${reset}`
|
||||
if (char === "~") return `${shadow}▀${reset}`
|
||||
if (char === " ") return " "
|
||||
return `${fg}${char}${reset}`
|
||||
})
|
||||
.join("")
|
||||
|
||||
return logo.left.map((line, index) => {
|
||||
const left = draw(line, dim, "\x1b[38;5;235m", "\x1b[48;5;235m")
|
||||
const right = draw(logo.right[index] ?? "", reset, "\x1b[38;5;238m", "\x1b[48;5;238m")
|
||||
return `${pad}${left} ${right}`
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionExitSummary(input: { title: string; sessionID?: string }) {
|
||||
const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}`
|
||||
return [
|
||||
...wordmark(" "),
|
||||
"",
|
||||
` ${weak("Session")}${bold}${input.title}${reset}`,
|
||||
` ${weak("Continue")}${bold}opencode -s ${input.sessionID}${reset}`,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
7
packages/tui/src/util/provider-origin.ts
Normal file
7
packages/tui/src/util/provider-origin.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
|
||||
Array.isArray(consoleManagedProviders)
|
||||
? consoleManagedProviders.includes(providerID)
|
||||
: consoleManagedProviders.has(providerID)
|
||||
|
||||
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
|
||||
contains(consoleManagedProviders, providerID)
|
||||
3
packages/tui/src/util/record.ts
Normal file
3
packages/tui/src/util/record.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
18
packages/tui/src/util/revert-diff.ts
Normal file
18
packages/tui/src/util/revert-diff.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { parsePatch } from "diff"
|
||||
|
||||
export function getRevertDiffFiles(diffText: string) {
|
||||
if (!diffText) return []
|
||||
|
||||
try {
|
||||
return parsePatch(diffText).map((patch) => {
|
||||
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
|
||||
return {
|
||||
filename: filename.replace(/^[ab]\//, ""),
|
||||
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
|
||||
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
27
packages/tui/src/util/scroll.ts
Normal file
27
packages/tui/src/util/scroll.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||
|
||||
export type ScrollConfig = {
|
||||
scroll_acceleration?: { enabled?: boolean }
|
||||
scroll_speed?: number
|
||||
}
|
||||
|
||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||
constructor(private speed: number) {}
|
||||
|
||||
tick(_now?: number): number {
|
||||
return this.speed
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
|
||||
if (tuiConfig?.scroll_acceleration?.enabled) {
|
||||
return new MacOSScrollAccel()
|
||||
}
|
||||
if (tuiConfig?.scroll_speed !== undefined) {
|
||||
return new CustomSpeedScroll(tuiConfig.scroll_speed)
|
||||
}
|
||||
|
||||
return new CustomSpeedScroll(3)
|
||||
}
|
||||
79
packages/tui/src/util/selection.ts
Normal file
79
packages/tui/src/util/selection.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { TuiPlatform } from "../platform"
|
||||
|
||||
type Toast = {
|
||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||
error: (err: unknown) => void
|
||||
}
|
||||
|
||||
type FocusableSelectionTarget = {
|
||||
hasSelection: () => boolean
|
||||
getClipboardText?: (text: string) => string
|
||||
}
|
||||
|
||||
type Renderer = {
|
||||
getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
|
||||
clearSelection: () => void
|
||||
currentFocusedRenderable?: FocusableSelectionTarget | null
|
||||
}
|
||||
|
||||
type SelectionKeyEvent = {
|
||||
ctrl?: boolean
|
||||
name: string
|
||||
preventDefault: () => void
|
||||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
|
||||
const text = selection.getSelectedText()
|
||||
if (!text) return false
|
||||
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
const clipboardText =
|
||||
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
||||
|
||||
clipboard
|
||||
?.write?.(clipboardText)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
export function handleSelectionKey(
|
||||
renderer: Renderer,
|
||||
toast: Toast,
|
||||
event: SelectionKeyEvent,
|
||||
clipboard: TuiPlatform["clipboard"],
|
||||
) {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
if (!copy(renderer, toast, clipboard)) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
renderer.clearSelection()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
|
||||
export * as Selection from "./selection"
|
||||
3
packages/tui/src/util/session.ts
Normal file
3
packages/tui/src/util/session.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isDefaultTitle(title: string) {
|
||||
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
}
|
||||
41
packages/tui/src/util/signal.ts
Normal file
41
packages/tui/src/util/signal.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
|
||||
|
||||
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
|
||||
const [get, set] = createSignal(value)
|
||||
return [get, debounce((v: T) => set(() => v), ms)]
|
||||
}
|
||||
|
||||
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
|
||||
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
|
||||
let revealed = show()
|
||||
|
||||
createEffect(
|
||||
on([show, enabled], ([visible, animate]) => {
|
||||
if (!visible) {
|
||||
setAlpha(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (!animate || revealed) {
|
||||
revealed = true
|
||||
setAlpha(1)
|
||||
return
|
||||
}
|
||||
|
||||
const start = performance.now()
|
||||
revealed = true
|
||||
setAlpha(0)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const progress = Math.min((performance.now() - start) / 160, 1)
|
||||
setAlpha(progress * progress * (3 - 2 * progress))
|
||||
if (progress >= 1) clearInterval(timer)
|
||||
}, 16)
|
||||
|
||||
onCleanup(() => clearInterval(timer))
|
||||
}),
|
||||
)
|
||||
|
||||
return alpha
|
||||
}
|
||||
13
packages/tui/src/util/tool-display.ts
Normal file
13
packages/tui/src/util/tool-display.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
|
||||
if (!("status" in state) || state.status === "pending") return {}
|
||||
if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
|
||||
if (Array.isArray(state.structured)) return {}
|
||||
return state.structured as Record<string, unknown>
|
||||
}
|
||||
112
packages/tui/src/util/transcript.ts
Normal file
112
packages/tui/src/util/transcript.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "./locale"
|
||||
import * as Model from "./model"
|
||||
|
||||
export type TranscriptOptions = {
|
||||
thinking: boolean
|
||||
toolDetails: boolean
|
||||
assistantMetadata: boolean
|
||||
providers?: Provider[]
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
title: string
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageWithParts = {
|
||||
info: UserMessage | AssistantMessage
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
export function formatTranscript(
|
||||
session: SessionInfo,
|
||||
messages: MessageWithParts[],
|
||||
options: TranscriptOptions,
|
||||
): string {
|
||||
const providers = Model.index(options.providers)
|
||||
let transcript = `# ${session.title}\n\n`
|
||||
transcript += `**Session ID:** ${session.id}\n`
|
||||
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
|
||||
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
|
||||
transcript += `---\n\n`
|
||||
|
||||
for (const msg of messages) {
|
||||
transcript += formatMessage(msg.info, msg.parts, options, providers)
|
||||
transcript += `---\n\n`
|
||||
}
|
||||
|
||||
return transcript
|
||||
}
|
||||
|
||||
export function formatMessage(
|
||||
msg: UserMessage | AssistantMessage,
|
||||
parts: Part[],
|
||||
options: TranscriptOptions,
|
||||
providers?: Provider[] | ReadonlyMap<string, Provider>,
|
||||
): string {
|
||||
let result = ""
|
||||
|
||||
if (msg.role === "user") {
|
||||
result += `## User\n\n`
|
||||
} else {
|
||||
result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
result += formatPart(part, options)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatAssistantHeader(
|
||||
msg: AssistantMessage,
|
||||
includeMetadata: boolean,
|
||||
providers?: Provider[] | ReadonlyMap<string, Provider>,
|
||||
): string {
|
||||
if (!includeMetadata) {
|
||||
return `## Assistant\n\n`
|
||||
}
|
||||
|
||||
const duration =
|
||||
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
|
||||
|
||||
const modelName = Model.name(providers, msg.providerID, msg.modelID)
|
||||
|
||||
return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n`
|
||||
}
|
||||
|
||||
export function formatPart(part: Part, options: TranscriptOptions): string {
|
||||
if (part.type === "text" && !part.synthetic) {
|
||||
return `${part.text}\n\n`
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
if (options.thinking) {
|
||||
return `_Thinking:_\n\n${part.text}\n\n`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
let result = `**Tool: ${part.tool}**\n`
|
||||
if (options.toolDetails && part.state.input) {
|
||||
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
|
||||
}
|
||||
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
|
||||
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
|
||||
}
|
||||
if (options.toolDetails && part.state.status === "error" && part.state.error) {
|
||||
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
|
||||
}
|
||||
result += `\n`
|
||||
return result
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue