refactor(tui): extract shared frontend helpers (#37868)
This commit is contained in:
parent
deee40c572
commit
0eb71d0fc7
52 changed files with 1655 additions and 1614 deletions
146
packages/tui/src/util/form.ts
Normal file
146
packages/tui/src/util/form.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
|
||||
export type FormAnswerField = Exclude<FormField, { type: "external" }>
|
||||
|
||||
export type FormRow = {
|
||||
value: string | boolean
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function isFormAnswerField(field: FormField): field is FormAnswerField {
|
||||
return field.type !== "external"
|
||||
}
|
||||
|
||||
export function formLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
export function formInitialValues(fields: ReadonlyArray<FormField>) {
|
||||
return {
|
||||
answers: Object.fromEntries(
|
||||
fields.flatMap((field) =>
|
||||
isFormAnswerField(field) && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
) as Record<string, FormValue | undefined>,
|
||||
custom: Object.fromEntries(
|
||||
fields.flatMap((field) => {
|
||||
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||
if (field.options.some((option) => option.value === field.default)) return []
|
||||
return [[field.key, field.default]]
|
||||
}),
|
||||
) as Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
export function formTextual(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
return field.type === "number" || field.type === "integer" || (field.type === "string" && !field.options)
|
||||
}
|
||||
|
||||
export function formCustom(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "string" && field.options) return field.custom === true
|
||||
return field.type === "multiselect" && field.custom === true
|
||||
}
|
||||
|
||||
export function formRows(field: FormField | undefined): FormRow[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
]
|
||||
const options = field.type === "multiselect" ? field.options : field.type === "string" ? field.options : undefined
|
||||
if (!options) return []
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formSelected(field: FormField | undefined, value: FormValue | undefined) {
|
||||
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||
const rows = formRows(field)
|
||||
const index = rows.findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && formCustom(field)) return rows.length
|
||||
return 0
|
||||
}
|
||||
|
||||
export function formValidateValue(field: FormAnswerField, value: FormValue | undefined): string | undefined {
|
||||
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0)))
|
||||
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return "Expected text"
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return `Must match pattern: ${field.pattern}`
|
||||
} catch {
|
||||
return `Invalid pattern: ${field.pattern}`
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "uri" && !validURL(value)) return "Expected a URL"
|
||||
if (field.format === "date" && !validDate(value)) return "Expected a date (YYYY-MM-DD)"
|
||||
if (field.format === "date-time" && Number.isNaN(new Date(value).getTime())) return "Expected a date and time"
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value))
|
||||
return "Select an available option"
|
||||
return
|
||||
}
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return
|
||||
}
|
||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||
if (!Array.isArray(value)) return "Expected selections"
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item)))
|
||||
return "Select only available options"
|
||||
}
|
||||
|
||||
export function formDisplayValue(field: FormAnswerField, value: FormValue | undefined, emptyMultiselect: string) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
formRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
if (Array.isArray(value)) return value.length === 0 ? emptyMultiselect : value.map(label).join(", ")
|
||||
return label(value)
|
||||
}
|
||||
|
||||
export function formToggleMultiselect(value: FormValue | undefined, item: string) {
|
||||
const values = Array.isArray(value) ? value : []
|
||||
const index = values.indexOf(item)
|
||||
return index === -1 ? [...values, item] : values.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
export function formSetMultiselectCustom(value: FormValue | undefined, previous: string | undefined, next: string) {
|
||||
const values = Array.isArray(value) ? value : []
|
||||
const index = previous ? values.indexOf(previous) : -1
|
||||
const current = index === -1 ? [...values] : values.toSpliced(index, 1)
|
||||
return next && !current.includes(next) ? [...current, next] : current
|
||||
}
|
||||
|
||||
function validURL(value: string) {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
37
packages/tui/src/util/path-format.ts
Normal file
37
packages/tui/src/util/path-format.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import path from "path"
|
||||
|
||||
export function abbreviateHome(input: string, home: string) {
|
||||
if (!home) return input
|
||||
const paths = windowsPath(home) ? path.win32 : path.posix
|
||||
const relative = paths.relative(home, input)
|
||||
if (!relative) return "~"
|
||||
if (relative === ".." || relative.startsWith(".." + paths.sep) || paths.isAbsolute(relative)) return input
|
||||
return "~/" + relative.split(paths.sep).join("/")
|
||||
}
|
||||
|
||||
export function formatPath(
|
||||
input: string | undefined,
|
||||
options: { base: string; home?: string; forwardSlashes?: boolean },
|
||||
) {
|
||||
if (!input) return ""
|
||||
const windows = windowsPath(options.base)
|
||||
if (!windows && windowsPath(input)) {
|
||||
return options.forwardSlashes ? input.replaceAll("\\", "/") : input
|
||||
}
|
||||
|
||||
const paths = windows ? path.win32 : path.posix
|
||||
const absolute = paths.isAbsolute(input) ? input : paths.resolve(options.base, input)
|
||||
const relative = paths.relative(options.base, absolute)
|
||||
const formatted = !relative
|
||||
? "."
|
||||
: relative !== ".." && !relative.startsWith(".." + paths.sep) && !paths.isAbsolute(relative)
|
||||
? relative
|
||||
: options.home
|
||||
? abbreviateHome(absolute, options.home)
|
||||
: absolute
|
||||
return options.forwardSlashes ? formatted.replaceAll("\\", "/") : formatted
|
||||
}
|
||||
|
||||
function windowsPath(input: string) {
|
||||
return /^[A-Za-z]:[\\/]/.test(input) || input.startsWith("\\\\")
|
||||
}
|
||||
188
packages/tui/src/util/permission.ts
Normal file
188
packages/tui/src/util/permission.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { Locale } from "./locale"
|
||||
import { canonicalToolName, finiteNumber, webSearchProviderLabel } from "./tool-display"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
|
||||
export type PermissionPresentation = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionPresentationInput = {
|
||||
action: string
|
||||
resources: ReadonlyArray<unknown>
|
||||
metadata?: unknown
|
||||
input?: unknown
|
||||
structured?: unknown
|
||||
}
|
||||
|
||||
export function permissionPresentation(
|
||||
source: PermissionPresentationInput,
|
||||
formatPath: (value: string) => string = (value) => value,
|
||||
): PermissionPresentation {
|
||||
const action = canonicalToolName(source.action)
|
||||
const input = normalizeInput(action, source.input)
|
||||
const metadata = { ...dict(source.structured), ...dict(source.metadata) }
|
||||
const resources = source.resources.filter((item): item is string => typeof item === "string")
|
||||
|
||||
if (action === "edit") {
|
||||
const file = text(input.path) || resources[0] || ""
|
||||
const first = dict(Array.isArray(metadata.files) ? metadata.files[0] : undefined)
|
||||
const diff = text(first.patch) || text(first.diff) || text(metadata.diff) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${formatPath(file)}`,
|
||||
lines: [],
|
||||
diff,
|
||||
patch: diff ? undefined : text(input.patchText) || undefined,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "read" || action === "list") {
|
||||
const value = text(input.path) || resources[0] || ""
|
||||
const title = action === "read" ? "Read" : "List"
|
||||
return {
|
||||
icon: "→",
|
||||
title: `${title} ${formatPath(value)}`,
|
||||
lines: value ? [`Path: ${formatPath(value)}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "glob" || action === "grep") {
|
||||
const pattern = text(input.pattern) || resources[0] || ""
|
||||
const title = action === "glob" ? "Glob" : "Grep"
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `${title} "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "shell") {
|
||||
const command = text(input.command)
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Shell command",
|
||||
lines: command ? [`$ ${command}`] : resources.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "subagent") {
|
||||
const agent = text(input.agent) || "general"
|
||||
const description = text(input.description)
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(agent)} Subagent`,
|
||||
lines: description ? [`◉ ${description}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "webfetch") {
|
||||
const url = text(input.url) || text(metadata.url)
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
lines: url ? [`URL: ${url}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "websearch") {
|
||||
const query = text(input.query) || text(metadata.query)
|
||||
const title = webSearchProviderLabel(metadata.provider)
|
||||
return {
|
||||
icon: "◈",
|
||||
title: query ? `${title} "${query}"` : title,
|
||||
lines: query ? [`Query: ${query}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "lsp") {
|
||||
const file = text(input.path)
|
||||
const operation = text(input.operation) || "request"
|
||||
const line = finiteNumber(input.line)
|
||||
const character = finiteNumber(input.character)
|
||||
const position = line !== undefined && character !== undefined ? `${line}:${character}` : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `LSP ${operation}${file ? ` ${formatPath(file)}${position ? `:${position}` : ""}` : ""}`,
|
||||
lines: [
|
||||
...(input.operation ? [`Operation: ${operation}`] : []),
|
||||
...(file ? [`Path: ${formatPath(file)}`] : []),
|
||||
...(position ? [`Position: ${position}`] : []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "external_directory") {
|
||||
const raw = text(metadata.parentDir) || text(metadata.filepath) || resources[0] || ""
|
||||
const directory = wildcardDirectory(raw)
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${formatPath(directory)}`,
|
||||
lines: resources.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${source.action}`,
|
||||
lines: [`Tool: ${source.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardDirectory(value: string) {
|
||||
const wildcard = value.indexOf("*")
|
||||
if (wildcard === -1) return value
|
||||
const prefix = value.slice(0, wildcard)
|
||||
if (/^[\\/]+$/.test(prefix) || /^[A-Za-z]:[\\/]$/.test(prefix)) return prefix
|
||||
return prefix.replace(/[\\/]+$/, "")
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(input: { action: string; save?: ReadonlyArray<string> }): string[] {
|
||||
const save = input.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${input.action} until OpenCode is restarted.`]
|
||||
}
|
||||
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
|
||||
}
|
||||
|
||||
export function permissionOptionLabel(option: "once" | "always" | "reject" | "confirm" | "cancel") {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
}
|
||||
|
||||
function normalizeInput(action: string, value: unknown): Dict {
|
||||
const input = dict(value)
|
||||
const path = text(input.path) || text(input.filePath) || text(input.filepath)
|
||||
const agent = text(input.agent) || text(input.subagent_type)
|
||||
return {
|
||||
...input,
|
||||
...(["read", "edit", "list", "lsp"].includes(action) && path ? { path } : {}),
|
||||
...(action === "subagent" && agent ? { agent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function dict(value: unknown): Dict {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
return value as Dict
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
|
@ -1,3 +1,24 @@
|
|||
export function canonicalToolName(name: string) {
|
||||
if (name === "bash") return "shell"
|
||||
if (name === "task") return "subagent"
|
||||
if (name === "apply_patch") return "patch"
|
||||
return name
|
||||
}
|
||||
|
||||
export function finiteNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function primitiveInputSummary(input: Record<string, unknown>, omit: readonly string[] = []) {
|
||||
const entries = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit.includes(key)) return false
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
})
|
||||
if (entries.length === 0) return ""
|
||||
return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
|
||||
}
|
||||
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue