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
|
|
@ -5,6 +5,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
|||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
|
|
@ -20,41 +21,6 @@ type RunFooterMenuRow =
|
|||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
|
||||
function previewMargin(limit: number) {
|
||||
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
|
||||
}
|
||||
|
||||
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < value) {
|
||||
return Math.min(max, input.selected)
|
||||
}
|
||||
|
||||
if (input.selected >= value + input.limit) {
|
||||
return Math.min(max, input.selected - input.limit + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = previewMargin(input.limit)
|
||||
if (input.dir < 0 && input.selected < value + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
|
||||
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
|
|
@ -63,15 +29,9 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
|||
|
||||
const reveal = (index: number) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
setSelected(0)
|
||||
setOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, index))
|
||||
const next = reconcileSelection(index, count)
|
||||
setSelected(next)
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
|
|
@ -81,28 +41,16 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
|||
|
||||
createEffect(() => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (selected() >= count) {
|
||||
setSelected(count - 1)
|
||||
}
|
||||
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
|
||||
const next = reconcileSelection(selected(), count)
|
||||
setSelected(next)
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
})
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, selected() + dir))
|
||||
const next = moveSelection(selected(), { count, delta: dir, policy: "clamp" })
|
||||
setSelected(next)
|
||||
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
|
||||
setOffset((value) => moveSelectionOffset(value, { count, limit: limit(), selected: next, direction: dir }))
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -169,8 +117,8 @@ export function RunFooterMenu(props: {
|
|||
const dir = props.selected() === previous + 1 ? 1 : props.selected() === previous - 1 ? -1 : undefined
|
||||
setGroupOffset((value) =>
|
||||
dir
|
||||
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
||||
: revealOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
? moveSelectionOffset(value, { count: all.length, limit: limit(), selected, direction: dir })
|
||||
: revealSelectionOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
)
|
||||
previous = props.selected()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ import {
|
|||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
promptCopy,
|
||||
pushPromptHistory,
|
||||
slashHead,
|
||||
} from "./prompt.shared"
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
|
|
@ -104,44 +105,12 @@ function clamp(rows: number): number {
|
|||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||
...(prompt.command ? { command: prompt.command } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPrompt(shell: boolean): RunPrompt {
|
||||
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||
}
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
return hash === -1 ? input : input.slice(0, hash)
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) {
|
||||
return { base: input }
|
||||
}
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const line = input.slice(hash + 1)
|
||||
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) {
|
||||
return { base }
|
||||
}
|
||||
|
||||
const start = Number(match[1])
|
||||
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, line: { start, end } }
|
||||
}
|
||||
|
||||
function slashQuery(text: string, cursor: number) {
|
||||
const head = slashHead(text.slice(0, cursor))
|
||||
const head = parseSlashHead(text.slice(0, cursor))
|
||||
if (!head || head.end !== cursor) {
|
||||
return
|
||||
}
|
||||
|
|
@ -150,7 +119,7 @@ function slashQuery(text: string, cursor: number) {
|
|||
}
|
||||
|
||||
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name.length === 0) {
|
||||
return { type: "none" as const }
|
||||
}
|
||||
|
|
@ -175,7 +144,7 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
|
|||
return
|
||||
}
|
||||
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name !== command.name) {
|
||||
return
|
||||
}
|
||||
|
|
@ -359,16 +328,16 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return []
|
||||
}
|
||||
|
||||
const next = extractLineRange(value)
|
||||
const next = parseFileLineRange(value)
|
||||
const list = await input.findFiles(next.base)
|
||||
return list.map((item): Auto => {
|
||||
const url = pathToFileURL(path.resolve(input.directory(), item))
|
||||
let filename = item
|
||||
if (next.line && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||
url.searchParams.set("start", String(next.line.start))
|
||||
if (next.line.end !== undefined) {
|
||||
url.searchParams.set("end", String(next.line.end))
|
||||
if (next.lineRange && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.lineRange.startLine}${next.lineRange.endLine ? `-${next.lineRange.endLine}` : ""}`
|
||||
url.searchParams.set("start", String(next.lineRange.startLine))
|
||||
if (next.lineRange.endLine !== undefined) {
|
||||
url.searchParams.set("end", String(next.lineRange.endLine))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -452,7 +421,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return mixed
|
||||
}
|
||||
|
||||
const next = removeLineRange(query())
|
||||
const next = stripFileLineRange(query())
|
||||
if (mode() === "mention") {
|
||||
return [
|
||||
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
|
|
@ -587,7 +556,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
draft = clonePrompt(value)
|
||||
draft = promptCopy(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
|
|
@ -726,7 +695,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
stash = clonePrompt(draft)
|
||||
stash = promptCopy(draft)
|
||||
}
|
||||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
|
|
@ -804,7 +773,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
syncDraft()
|
||||
hide()
|
||||
|
||||
const current = clonePrompt(draft)
|
||||
const current = promptCopy(draft)
|
||||
try {
|
||||
const content = await input.onEditorOpen({
|
||||
value: inputValue?.value ?? current.text,
|
||||
|
|
@ -847,7 +816,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const head = slashHead(area.plainText)
|
||||
const head = parseSlashHead(area.plainText)
|
||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
|
||||
const text = `/${next.name}${separator}`
|
||||
|
|
@ -864,7 +833,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
submitPrompt(clonePrompt(draft))
|
||||
submitPrompt(promptCopy(draft))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1127,7 +1096,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = clonePrompt(next)
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
|
|
@ -1183,7 +1152,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const onSubmit = () => {
|
||||
syncDraft()
|
||||
submitPrompt(clonePrompt(draft))
|
||||
submitPrompt(promptCopy(draft))
|
||||
}
|
||||
|
||||
const submitText = (text: string) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
} from "../util/form"
|
||||
import type { FormAnswerField } from "../util/form"
|
||||
import type { FormReply, MiniFormRequest } from "./types"
|
||||
|
||||
type AnswerField = Exclude<FormField, { type: "external" }>
|
||||
export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue }
|
||||
|
||||
export type FormBodyState = {
|
||||
formID: string
|
||||
|
|
@ -15,31 +28,14 @@ export type FormBodyState = {
|
|||
error: string
|
||||
}
|
||||
|
||||
export type FormRow = {
|
||||
value: string | boolean
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function createFormBodyState(form: FormInfo): FormBodyState {
|
||||
const answers = Object.fromEntries(
|
||||
form.fields.flatMap((field) =>
|
||||
field.type !== "external" && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
)
|
||||
const custom = Object.fromEntries(
|
||||
form.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]]
|
||||
}),
|
||||
)
|
||||
const initial = formInitialValues(form.fields)
|
||||
return {
|
||||
formID: form.id,
|
||||
field: 0,
|
||||
answers,
|
||||
custom,
|
||||
selected: formSelected(form.fields[0], answers[form.fields[0]?.key ?? ""]),
|
||||
answers: initial.answers,
|
||||
custom: initial.custom,
|
||||
selected: formSelected(form.fields[0], initial.answers[form.fields[0]?.key ?? ""]),
|
||||
editing: formTextual(form.fields[0]),
|
||||
externalReady: {},
|
||||
submitting: false,
|
||||
|
|
@ -65,10 +61,6 @@ export function formUnsupported(form: FormInfo): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
export function formLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
export function formCurrent(form: FormInfo, state: FormBodyState) {
|
||||
return form.fields[state.field]
|
||||
}
|
||||
|
|
@ -89,46 +81,11 @@ export function formSingle(form: FormInfo) {
|
|||
)
|
||||
}
|
||||
|
||||
export function formTextual(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
return field.type === "number" || field.type === "integer" || (field.type === "string" && !field.options)
|
||||
}
|
||||
|
||||
export function formPlaceholder(field: FormField | undefined) {
|
||||
if (field?.type === "string") return field.placeholder ?? "Type your answer"
|
||||
return "Enter a number"
|
||||
}
|
||||
|
||||
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 index = formRows(field).findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && formCustom(field)) return formRows(field).length
|
||||
return 0
|
||||
}
|
||||
|
||||
export function formMove(state: FormBodyState, form: FormInfo, direction: -1 | 1): FormBodyState {
|
||||
const field = formCurrent(form, state)
|
||||
const total = formRows(field).length + (formCustom(field) ? 1 : 0)
|
||||
|
|
@ -166,40 +123,6 @@ export function formSetDraft(state: FormBodyState, field: FormField | undefined,
|
|||
return { ...state, custom: { ...state.custom, [field.key]: value } }
|
||||
}
|
||||
|
||||
export function formValidateValue(field: AnswerField, 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.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.required && value.length === 0) return "Select at least one option"
|
||||
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 formValidate(form: FormInfo, state: FormBodyState): string | undefined {
|
||||
const unsupported = formUnsupported(form)
|
||||
if (unsupported) return unsupported
|
||||
|
|
@ -249,13 +172,11 @@ export function formPick(state: FormBodyState, form: FormInfo): FormBodyState {
|
|||
const row = rows[state.selected]
|
||||
if (!row) return state
|
||||
if (field.type === "multiselect") {
|
||||
const answer = state.answers[field.key]
|
||||
const values = Array.isArray(answer) ? [...answer] : []
|
||||
const value = String(row.value)
|
||||
const index = values.indexOf(value)
|
||||
if (index === -1) values.push(value)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
return { ...state, answers: { ...state.answers, [field.key]: values }, error: "" }
|
||||
return {
|
||||
...state,
|
||||
answers: { ...state.answers, [field.key]: formToggleMultiselect(state.answers[field.key], String(row.value)) },
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
...state,
|
||||
|
|
@ -271,14 +192,7 @@ export function formCommitInput(state: FormBodyState, form: FormInfo, text: stri
|
|||
const input = text.trim()
|
||||
const value = !input ? undefined : field.type === "number" || field.type === "integer" ? Number(input) : input
|
||||
if (field.type === "multiselect") {
|
||||
const answer = state.answers[field.key]
|
||||
const values = Array.isArray(answer) ? [...answer] : []
|
||||
const previous = state.custom[field.key]
|
||||
if (previous) {
|
||||
const index = values.indexOf(previous)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
}
|
||||
if (input && !values.includes(input)) values.push(input)
|
||||
const values = formSetMultiselectCustom(state.answers[field.key], state.custom[field.key], input)
|
||||
const invalid = formValidateValue(field, values)
|
||||
if (invalid) return formSetError(state, invalid)
|
||||
return {
|
||||
|
|
@ -311,11 +225,8 @@ export function formAcknowledge(state: FormBodyState, form: FormInfo): FormBodyS
|
|||
return formSetField(next, form, formSingle(form) ? state.field : state.field + 1)
|
||||
}
|
||||
|
||||
export function formDisplay(field: AnswerField, value: FormValue | undefined) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
formRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
return Array.isArray(value) ? value.map(label).join(", ") : label(value)
|
||||
export function formDisplay(field: FormAnswerField, value: FormValue | undefined) {
|
||||
return formDisplayValue(field, value, "")
|
||||
}
|
||||
|
||||
export function formErrorMessage(error: unknown) {
|
||||
|
|
@ -328,18 +239,3 @@ export function formErrorMessage(error: unknown) {
|
|||
}
|
||||
return "Form request failed"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,6 @@
|
|||
// Pure state machine for the permission UI.
|
||||
//
|
||||
// Lives outside the JSX component so it can be tested independently. The
|
||||
// machine has three stages:
|
||||
//
|
||||
// permission → initial view with Allow once / Always / Reject options
|
||||
// always → confirmation step (Confirm / Cancel)
|
||||
// reject → text input for rejection message
|
||||
//
|
||||
// permissionRun() is the main transition: given the current state and the
|
||||
// selected option, it returns a new state and optionally a PermissionReply
|
||||
// to send to the SDK. The component calls this on enter/click.
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../util/permission"
|
||||
import { toolPath } from "./tool"
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
|
|
@ -30,46 +14,11 @@ export type PermissionBodyState = {
|
|||
submitting: boolean
|
||||
}
|
||||
|
||||
export type PermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionStep = {
|
||||
state: PermissionBodyState
|
||||
reply?: PermissionReply
|
||||
}
|
||||
|
||||
function dict(v: unknown): Dict {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { ...v }
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: MiniPermissionRequest): { input: Dict; metadata: Dict } {
|
||||
const state = request.tool?.state
|
||||
const metadata = {
|
||||
...(state && state.status !== "streaming" ? dict(state.structured) : {}),
|
||||
...dict(request.metadata),
|
||||
}
|
||||
if (!state || state.status === "streaming") return { input: {}, metadata }
|
||||
return { input: dict(state.input), metadata }
|
||||
}
|
||||
|
||||
function patterns(request: MiniPermissionRequest): string[] {
|
||||
return request.resources.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(
|
||||
request: Pick<MiniPermissionRequest, "id" | "sessionID">,
|
||||
): PermissionBodyState {
|
||||
|
|
@ -95,57 +44,26 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
|||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const source = data(request)
|
||||
const info = toolPermissionInfo(request.action, source.input, source.metadata, pats, directory)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.action === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${toolPath(dir, { home: true, directory })}`,
|
||||
lines: pats.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (request.action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.action}`,
|
||||
lines: [`Tool: ${request.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: MiniPermissionRequest): string[] {
|
||||
const save = request.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string) {
|
||||
const state = request.tool?.state
|
||||
return permissionPresentation(
|
||||
{
|
||||
action: request.action,
|
||||
resources: request.resources,
|
||||
metadata: request.metadata,
|
||||
input: state?.status === "streaming" ? undefined : state?.input,
|
||||
structured: state?.status === "streaming" ? undefined : state?.structured,
|
||||
},
|
||||
(value) => toolPath(value, { home: true, directory }),
|
||||
)
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
return permissionOptionLabel(option)
|
||||
}
|
||||
|
||||
export { permissionAlwaysLines }
|
||||
|
||||
export function permissionReply(
|
||||
sessionID: string,
|
||||
requestID: string,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { RunPromptPart } from "./types"
|
||||
import { slashHead } from "./prompt.shared"
|
||||
import { realignPromptMentions } from "../prompt/mention"
|
||||
import { parseSlashHead } from "../prompt/parse"
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
export function resolveEditorSlashValue(text: string) {
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name.toLowerCase() !== "editor") {
|
||||
return text
|
||||
}
|
||||
|
|
@ -13,95 +14,27 @@ export function resolveEditorSlashValue(text: string) {
|
|||
}
|
||||
|
||||
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
|
||||
const matches = new Map<number, Mention | undefined>()
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
const matches = realignPromptMentions(
|
||||
content,
|
||||
parts.map((part) => {
|
||||
if (part.type !== "file" && part.type !== "agent") return
|
||||
return promptPartMention(part)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = promptPartText(part)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
|
||||
if (start === -1) {
|
||||
matches.set(index, undefined)
|
||||
continue
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
used.push({ start, end })
|
||||
matches.set(index, updatePromptPart(part, start, end, text))
|
||||
}
|
||||
|
||||
const next: RunPromptPart[] = []
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!promptPartText(part)) {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
const match = matches.get(index)
|
||||
if (match) {
|
||||
next.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
return parts.flatMap((part, index) => {
|
||||
if (part.type !== "file" && part.type !== "agent") return [part]
|
||||
const mention = promptPartMention(part)
|
||||
if (!mention?.text) return [part]
|
||||
const match = matches[index]
|
||||
return match ? [updatePromptPart(part, match.start, match.end, match.text)] : []
|
||||
})
|
||||
}
|
||||
|
||||
function promptPartText(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.value
|
||||
}
|
||||
|
||||
return part.source?.text.value
|
||||
}
|
||||
|
||||
function promptPartStart(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
return part.source?.text.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
|
||||
let searchFrom = 0
|
||||
let best = -1
|
||||
let distance = Number.POSITIVE_INFINITY
|
||||
const hinted = Number.isFinite(hint)
|
||||
|
||||
while (true) {
|
||||
const start = content.indexOf(text, searchFrom)
|
||||
if (start === -1) {
|
||||
return best
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
searchFrom = start + 1
|
||||
if (used.some((range) => start < range.end && end > range.start)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hinted) {
|
||||
return start
|
||||
}
|
||||
|
||||
const nextDistance = Math.abs(start - hint)
|
||||
if (nextDistance < distance) {
|
||||
best = start
|
||||
distance = nextDistance
|
||||
}
|
||||
}
|
||||
function promptPartMention(part: Mention) {
|
||||
const source = part.type === "agent" ? part.source : part.source?.text
|
||||
if (!source) return
|
||||
return { start: source.start, end: source.end, text: source.value }
|
||||
}
|
||||
|
||||
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
|
||||
|
|
|
|||
|
|
@ -53,14 +53,6 @@ export function isNewCommand(input: string): boolean {
|
|||
return input.trim().toLowerCase() === "/new"
|
||||
}
|
||||
|
||||
export function slashHead(text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const end = text.slice(1).search(/[ \t\n]/)
|
||||
if (end === -1) return { name: text.slice(1), arguments: "", end: text.length }
|
||||
const split = end + 1
|
||||
return { name: text.slice(1, split), arguments: text.slice(split + 1), end: split }
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
// palette if detection fails.
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { resolveThemeColors } from "../theme/resolve"
|
||||
import { terminalMode } from "../theme/system"
|
||||
import type { ThemeJson } from "../theme/v1"
|
||||
import type { EntryKind, RunTuiConfig } from "./types"
|
||||
|
||||
type Tone = {
|
||||
|
|
@ -63,21 +67,6 @@ export type RunTheme = {
|
|||
}
|
||||
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
type ThemeJson = {
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
type SharedSyntaxTheme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
|
|
@ -94,7 +83,7 @@ function rgba(hex: string, value?: number): RGBA {
|
|||
return value === undefined ? color : alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
function colorMode(bg: RGBA): "dark" | "light" {
|
||||
return luminance(bg) > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
|
|
@ -118,46 +107,6 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
|
|||
)
|
||||
}
|
||||
|
||||
function ansiToRgba(code: number): RGBA {
|
||||
if (code < 16) {
|
||||
const ansi = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(ansi[code] ?? "#000000")
|
||||
}
|
||||
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const b = index % 6
|
||||
const g = Math.floor(index / 6) % 6
|
||||
const r = Math.floor(index / 36)
|
||||
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
|
||||
return RGBA.fromInts(value(r), value(g), value(b))
|
||||
}
|
||||
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
|
||||
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
return RGBA.fromInts(
|
||||
Math.round((base.r + (overlay.r - base.r) * value) * 255),
|
||||
|
|
@ -236,54 +185,10 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number)
|
|||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
|
||||
if (value instanceof RGBA) return value
|
||||
|
||||
if (typeof value === "number") {
|
||||
return RGBA.fromIndex(value, ansiToRgba(value))
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return resolveColor(value[pick], chain)
|
||||
}
|
||||
|
||||
if (value === "transparent" || value === "none") {
|
||||
return RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
return RGBA.fromHex(value)
|
||||
}
|
||||
|
||||
if (chain.includes(value)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
}
|
||||
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText:
|
||||
theme.theme.selectedListItemText === undefined
|
||||
? resolved.background!
|
||||
: resolveColor(theme.theme.selectedListItemText),
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
...resolved.theme,
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,7 +370,7 @@ function map(
|
|||
syntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const footerMode = colorMode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
|
||||
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
|
||||
|
|
@ -616,9 +521,7 @@ export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConf
|
|||
const pick =
|
||||
config?.mode === "dark" || config?.mode === "light"
|
||||
? config.mode
|
||||
: colors.defaultBackground
|
||||
? mode(RGBA.fromHex(colors.defaultBackground))
|
||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||
: (terminalMode(colors) ?? renderer.themeMode ?? colorMode(RGBA.fromHex(bg)))
|
||||
const { generateSyntax } = await import("../theme")
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// Per-tool display rules shared across `opencode run` output paths.
|
||||
//
|
||||
// Each known tool (shell, edit, write, subagent, etc.) has a ToolRule that controls
|
||||
// five display hooks:
|
||||
// four display hooks:
|
||||
//
|
||||
// view → visibility policy for progress/final scrollback entries and
|
||||
// whether completed finals can render as structured snapshots
|
||||
// run → inline summary for the non-interactive `run` command output
|
||||
// scroll → text formatting for start/progress/final scrollback entries
|
||||
// permission → display info for the permission UI (icon, title, diff)
|
||||
// snap → structured snapshot (code block, diff, task card) for rich
|
||||
// scrollback entries
|
||||
//
|
||||
|
|
@ -18,9 +17,18 @@ import stripAnsi from "strip-ansi"
|
|||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { LANGUAGE_EXTENSIONS } from "../util/filetype"
|
||||
import { Locale } from "../util/locale"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../util/tool-display"
|
||||
import { formatPath } from "../util/path-format"
|
||||
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type { MiniToolPart } from "./types"
|
||||
export { canonicalToolName } from "../util/tool-display"
|
||||
|
||||
export type ToolView = {
|
||||
output: boolean
|
||||
|
|
@ -92,35 +100,12 @@ export type ToolInline = {
|
|||
body?: string
|
||||
}
|
||||
|
||||
export type ToolPermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolProps = {
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
frame: ToolFrame
|
||||
}
|
||||
|
||||
type ToolPermissionProps = {
|
||||
directory?: string
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
type ToolPermissionCtx = {
|
||||
directory?: string
|
||||
input: ToolDict
|
||||
meta: ToolDict
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
type ToolName =
|
||||
| "invalid"
|
||||
| "shell"
|
||||
|
|
@ -144,7 +129,6 @@ type ToolRule = {
|
|||
view: ToolView
|
||||
run: (props: ToolProps) => ToolInline
|
||||
scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
|
||||
permission?: (props: ToolPermissionProps) => ToolPermissionInfo
|
||||
snap?: (props: ToolProps) => ToolSnapshot | undefined
|
||||
}
|
||||
|
||||
|
|
@ -168,21 +152,6 @@ function props(frame: ToolFrame): ToolProps {
|
|||
}
|
||||
}
|
||||
|
||||
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
|
||||
return {
|
||||
directory: ctx.directory,
|
||||
input: ctx.input,
|
||||
metadata: ctx.meta,
|
||||
patterns: ctx.patterns,
|
||||
}
|
||||
}
|
||||
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
|
@ -193,13 +162,6 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri
|
|||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||
}
|
||||
|
||||
export function canonicalToolName(name: string) {
|
||||
if (name === "bash") return "shell"
|
||||
if (name === "task") return "subagent"
|
||||
if (name === "apply_patch") return "patch"
|
||||
return name
|
||||
}
|
||||
|
||||
function normalizeInput(name: string, value: unknown) {
|
||||
const input = dict(value)
|
||||
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
|
||||
|
|
@ -228,7 +190,7 @@ function normalizeFile(value: unknown): PatchFile | undefined {
|
|||
? "moved"
|
||||
: legacy)
|
||||
const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined
|
||||
const deletions = num(file.deletions)
|
||||
const deletions = finiteNumber(file.deletions)
|
||||
return {
|
||||
...file,
|
||||
file: name,
|
||||
|
|
@ -250,8 +212,10 @@ function normalizeStructured(name: string, value: unknown) {
|
|||
...structured,
|
||||
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
|
||||
...(name === "subagent" && sessionID ? { sessionID } : {}),
|
||||
...(name === "shell" && num(structured.exit) === undefined && num(structured.exitCode) !== undefined
|
||||
? { exit: num(structured.exitCode) }
|
||||
...(name === "shell" &&
|
||||
finiteNumber(structured.exit) === undefined &&
|
||||
finiteNumber(structured.exitCode) !== undefined
|
||||
? { exit: finiteNumber(structured.exitCode) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -265,19 +229,11 @@ export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessage
|
|||
state: {
|
||||
...tool.state,
|
||||
input: normalizeInput(name, tool.state.input),
|
||||
structured: normalizeStructured(name, tool.state.structured),
|
||||
structured: normalizeStructured(name, toolDisplayMetadata(tool.state)),
|
||||
},
|
||||
} as SessionMessageAssistantTool
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
function list<T>(v: unknown): T[] {
|
||||
if (!Array.isArray(v)) {
|
||||
return []
|
||||
|
|
@ -286,22 +242,6 @@ function list<T>(v: unknown): T[] {
|
|||
return v
|
||||
}
|
||||
|
||||
function info(data: ToolDict, skip: string[] = []): string {
|
||||
const list = Object.entries(data).filter(([key, val]) => {
|
||||
if (skip.includes(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
|
||||
})
|
||||
|
||||
if (list.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
|
||||
}
|
||||
|
||||
function span(frame: ToolFrame): string {
|
||||
const start = frame.time.start
|
||||
const end = frame.time.end
|
||||
|
|
@ -335,7 +275,7 @@ function toolError(ctx: ToolFrame): string {
|
|||
}
|
||||
|
||||
function fallbackStart(ctx: ToolFrame): string {
|
||||
const extra = info(ctx.input)
|
||||
const extra = primitiveInputSummary(ctx.input)
|
||||
if (!extra) {
|
||||
return `⚙ ${ctx.name}`
|
||||
}
|
||||
|
|
@ -361,28 +301,11 @@ function fallbackFinal(ctx: ToolFrame): string {
|
|||
}
|
||||
|
||||
export function toolPath(input?: string, opts: { home?: boolean; directory?: string } = {}): string {
|
||||
if (!input) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const cwd = opts.directory ?? process.cwd()
|
||||
const home = os.homedir()
|
||||
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const rel = path.relative(cwd, abs)
|
||||
|
||||
if (!rel) {
|
||||
return "."
|
||||
}
|
||||
|
||||
if (!rel.startsWith("..")) {
|
||||
return rel.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
if (opts.home && home && (abs === home || abs.startsWith(home + path.sep))) {
|
||||
return abs.replace(home, "~").replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
return abs.replaceAll("\\", "/")
|
||||
return formatPath(input, {
|
||||
base: opts.directory ?? process.cwd(),
|
||||
home: opts.home ? os.homedir() : undefined,
|
||||
forwardSlashes: true,
|
||||
})
|
||||
}
|
||||
|
||||
function displayPath(p: ToolProps, input?: string, opts: { home?: boolean } = {}) {
|
||||
|
|
@ -438,7 +361,7 @@ function runList(p: ToolProps): ToolInline {
|
|||
|
||||
function runRead(p: ToolProps): ToolInline {
|
||||
const file = displayPath(p, p.input.path)
|
||||
const description = info(p.frame.input, ["path"]) || undefined
|
||||
const description = primitiveInputSummary(p.frame.input, ["path"]) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${file}`,
|
||||
|
|
@ -748,7 +671,7 @@ function scrollShellFinal(p: ToolProps): string {
|
|||
return fail(p.frame)
|
||||
}
|
||||
|
||||
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
||||
const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code)
|
||||
const time = span(p.frame)
|
||||
if (code === undefined) {
|
||||
if (!time) {
|
||||
|
|
@ -763,7 +686,7 @@ function scrollShellFinal(p: ToolProps): string {
|
|||
|
||||
function scrollReadStart(p: ToolProps): string {
|
||||
const file = displayPath(p, p.input.path)
|
||||
const extra = info(p.frame.input, ["path"])
|
||||
const extra = primitiveInputSummary(p.frame.input, ["path"])
|
||||
const tail = extra ? ` ${extra}` : ""
|
||||
return `→ Read ${file}${tail}`.trim()
|
||||
}
|
||||
|
|
@ -953,109 +876,6 @@ function scrollWebSearchStart(p: ToolProps): string {
|
|||
return `◈ ${title} "${query}"`
|
||||
}
|
||||
|
||||
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || p.patterns[0] || ""
|
||||
const diff = (list<PatchFile>(p.metadata.files)[0]?.patch ?? text(p.metadata.diff)) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||
lines: [],
|
||||
diff,
|
||||
patch: diff ? undefined : text(p.input.patchText) || undefined,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||
lines: file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Glob "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Grep "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const dir = text(dict(p.input).path) || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${toolPath(dir, { home: true, directory: p.directory })}`,
|
||||
lines: dir ? [`Path: ${toolPath(dir, { home: true, directory: p.directory })}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permBash(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const cmd = p.input.command || ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Shell command",
|
||||
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const type = p.input.agent || "general"
|
||||
const desc = p.input.description
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(type)} Subagent`,
|
||||
lines: desc ? [`◉ ${desc}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const url = p.input.url || ""
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
lines: url ? [`URL: ${url}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const query = p.input.query || ""
|
||||
const title = webSearchProviderLabel(p.metadata.provider)
|
||||
return {
|
||||
icon: "◈",
|
||||
title: query ? `${title} "${query}"` : title,
|
||||
lines: query ? [`Query: ${query}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || ""
|
||||
const line = typeof p.input.line === "number" ? p.input.line : undefined
|
||||
const char = typeof p.input.character === "number" ? p.input.character : undefined
|
||||
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: lspTitle(p.input, { home: true, directory: p.directory }),
|
||||
lines: [
|
||||
...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
|
||||
...(file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : []),
|
||||
...(pos ? [`Position: ${pos}`] : []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_RULES = {
|
||||
invalid: {
|
||||
view: {
|
||||
|
|
@ -1078,7 +898,6 @@ const TOOL_RULES = {
|
|||
progress: scrollBashProgress,
|
||||
final: scrollShellFinal,
|
||||
},
|
||||
permission: permBash,
|
||||
},
|
||||
write: {
|
||||
view: {
|
||||
|
|
@ -1103,7 +922,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollEditStart,
|
||||
},
|
||||
permission: permEdit,
|
||||
},
|
||||
patch: {
|
||||
view: {
|
||||
|
|
@ -1140,7 +958,6 @@ const TOOL_RULES = {
|
|||
start: scrollTaskStart,
|
||||
final: scrollTaskFinal,
|
||||
},
|
||||
permission: permTask,
|
||||
},
|
||||
question: {
|
||||
view: {
|
||||
|
|
@ -1164,7 +981,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollReadStart,
|
||||
},
|
||||
permission: permRead,
|
||||
},
|
||||
glob: {
|
||||
view: {
|
||||
|
|
@ -1176,7 +992,6 @@ const TOOL_RULES = {
|
|||
start: scrollGlobStart,
|
||||
final: scrollGlobFinal,
|
||||
},
|
||||
permission: permGlob,
|
||||
},
|
||||
grep: {
|
||||
view: {
|
||||
|
|
@ -1187,7 +1002,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollGrepStart,
|
||||
},
|
||||
permission: permGrep,
|
||||
},
|
||||
list: {
|
||||
view: {
|
||||
|
|
@ -1198,7 +1012,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollListStart,
|
||||
},
|
||||
permission: permList,
|
||||
},
|
||||
lsp: {
|
||||
view: {
|
||||
|
|
@ -1209,7 +1022,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollLspStart,
|
||||
},
|
||||
permission: permLsp,
|
||||
},
|
||||
webfetch: {
|
||||
view: {
|
||||
|
|
@ -1220,7 +1032,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollWebfetchStart,
|
||||
},
|
||||
permission: permWebfetch,
|
||||
},
|
||||
websearch: {
|
||||
view: {
|
||||
|
|
@ -1231,7 +1042,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollWebSearchStart,
|
||||
},
|
||||
permission: permWebSearch,
|
||||
},
|
||||
skill: {
|
||||
view: {
|
||||
|
|
@ -1385,33 +1195,6 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
|
|||
return fallbackFinal(ctx)
|
||||
}
|
||||
|
||||
export function toolPermissionInfo(
|
||||
name: string,
|
||||
input: ToolDict,
|
||||
meta: ToolDict,
|
||||
patterns: string[],
|
||||
directory?: string,
|
||||
): ToolPermissionInfo | undefined {
|
||||
const normalized = canonicalToolName(name)
|
||||
const draw = rule(normalized)?.permission
|
||||
if (!draw) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return draw(
|
||||
permission({
|
||||
directory,
|
||||
input: normalizeInput(normalized, input),
|
||||
meta: normalizeStructured(normalized, meta),
|
||||
patterns,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const draw = rule(ctx.name)?.snap
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
// Model variant resolution and persistence.
|
||||
//
|
||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
||||
// Resolution priority: CLI --variant flag > valid session history > saved preference.
|
||||
//
|
||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
||||
import type { RunInput, RunProvider } from "./types"
|
||||
import { cycleModelVariant, normalizeModelVariant } from "../model-preference"
|
||||
|
||||
export function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
|
||||
const provider = providers?.find((item) => item.id === model.providerID)
|
||||
|
|
@ -28,20 +29,7 @@ export function formatModelLabel(
|
|||
}
|
||||
|
||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const idx = variants.indexOf(current)
|
||||
if (idx === -1 || idx === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[idx + 1]
|
||||
return cycleModelVariant(current, variants)
|
||||
}
|
||||
|
||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
||||
|
|
@ -49,20 +37,13 @@ export function pickVariant(model: RunInput["model"], input: RunSession | Sessio
|
|||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
const normalized = normalizeModelVariant(value)
|
||||
return normalized && (variants.length === 0 || variants.includes(normalized)) ? normalized : undefined
|
||||
}
|
||||
|
||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
||||
// history. fitVariant() checks saved and session values against the available
|
||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
||||
// Picks the active variant. CLI flag wins, then valid session history, then the
|
||||
// saved preference. Saved and session values are dropped when the provider no
|
||||
// longer offers them.
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
|
|
@ -70,14 +51,8 @@ export function resolveVariant(
|
|||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
return normalizeModelVariant(input)
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fitVariant(session, variants) ?? fitVariant(saved, variants)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue