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
|
|
@ -1,8 +1,9 @@
|
|||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import fs from "node:fs"
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { ReadStream } from "node:tty"
|
||||
|
||||
|
|
@ -14,51 +15,17 @@ export type InteractiveStdin = {
|
|||
}
|
||||
|
||||
type MiniHost = MiniFrontendInput["host"]
|
||||
type ModelState = Record<string, unknown> & {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function state(value: unknown): ModelState {
|
||||
if (!isRecord(value)) return {}
|
||||
const variant = isRecord(value.variant)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value.variant).flatMap(([key, item]) =>
|
||||
typeof item === "string" ? ([[key, item]] as const) : [],
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return { ...value, variant }
|
||||
}
|
||||
|
||||
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
|
||||
return `${model.providerID}/${model.modelID}`
|
||||
}
|
||||
|
||||
function preferences(statePath: string): MiniHost["preferences"] {
|
||||
const file = path.join(statePath, "model.json")
|
||||
const read = () =>
|
||||
readFile(file, "utf8")
|
||||
.then((value) => state(JSON.parse(value)))
|
||||
.catch(() => state(undefined))
|
||||
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
|
||||
return {
|
||||
async resolveVariant(model) {
|
||||
if (!model) return
|
||||
const variant = (await read()).variant?.[variantKey(model)]
|
||||
return variant === "default" ? undefined : variant
|
||||
return repository.resolveVariant(model)
|
||||
},
|
||||
async saveVariant(model, variant) {
|
||||
if (!model) return
|
||||
const current = await read()
|
||||
const next = { ...current.variant }
|
||||
if (variant) next[variantKey(model)] = variant
|
||||
if (!variant) delete next[variantKey(model)]
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
.then(() => writeFile(file, JSON.stringify({ ...current, variant: next }, null, 2)))
|
||||
.catch(() => {})
|
||||
await repository.saveVariant(model, variant).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default defineScript({
|
|||
|
||||
const registration = await serviceRegistration(artifacts)
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
||||
const session = `mini-stage2-${process.pid}`
|
||||
const snapshots = path.join(artifacts, "mini-stage2")
|
||||
await mkdir(snapshots, { recursive: true })
|
||||
|
|
@ -56,7 +57,7 @@ export default defineScript({
|
|||
"OPENCODE_DIRECT_TRACE=1",
|
||||
process.execPath,
|
||||
"--conditions=browser",
|
||||
"--preload=@opentui/solid/preload",
|
||||
`--preload=${preload}`,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"mini",
|
||||
"--server",
|
||||
|
|
|
|||
|
|
@ -159,37 +159,19 @@ describe("Mini CLI host", () => {
|
|||
expect(typeof input.startup.now()).toBe("number")
|
||||
})
|
||||
|
||||
test("merges, clears, and repairs persisted model variants", async () => {
|
||||
test("delegates model variant preferences", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
const file = path.join(directory, "model.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low", invalid: 42 },
|
||||
}),
|
||||
)
|
||||
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await input.preferences.resolveVariant(model)).toBe("high")
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
|
||||
})
|
||||
|
||||
await input.preferences.saveVariant(model, undefined)
|
||||
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||
variant: { "openai/gpt-4.1": "low" },
|
||||
})
|
||||
|
||||
await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } }))
|
||||
await input.preferences.saveVariant(model, "default")
|
||||
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||
|
||||
await Bun.write(file, "{")
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
||||
expect(await input.preferences.resolveVariant(model)).toBe("high")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,13 +23,7 @@ describe("Snapshot", () => {
|
|||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
|
||||
|
|
@ -99,13 +93,7 @@ describe("Snapshot", () => {
|
|||
await fs.mkdir(location, { recursive: true })
|
||||
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
|
||||
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
const layer = snapshotLayer(tmp.path, location)
|
||||
|
|
@ -168,14 +156,8 @@ describe("Snapshot", () => {
|
|||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
await initGit(project, true)
|
||||
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const capture = (directory: string) =>
|
||||
|
|
@ -213,13 +195,7 @@ describe("Snapshot", () => {
|
|||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await $`git init`.cwd(project).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(project).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(project).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(project).quiet()
|
||||
await $`git config user.name Test`.cwd(project).quiet()
|
||||
await $`git add .`.cwd(project).quiet()
|
||||
await $`git commit -m initial`.cwd(project).quiet()
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
|
|
@ -251,3 +227,12 @@ function snapshotLayer(data: string, directory: string) {
|
|||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
|
||||
}
|
||||
|
||||
async function initGit(directory: string, commit = false) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
if (!commit) return
|
||||
await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
|
||||
.cwd(directory)
|
||||
.quiet()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"./editor-zed": "./src/editor-zed.ts",
|
||||
"./mini": "./src/mini/index.ts",
|
||||
"./mini/tool": "./src/mini/tool.ts",
|
||||
"./model-preference": "./src/model-preference.ts",
|
||||
"./runtime": "./src/runtime.tsx",
|
||||
"./terminal-win32": "./src/terminal-win32.ts",
|
||||
"./context/keymap": "./src/context/keymap.tsx",
|
||||
|
|
|
|||
|
|
@ -22,38 +22,7 @@ import { Keymap } from "../../context/keymap"
|
|||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
if (hashIndex === -1) {
|
||||
return { baseQuery: input }
|
||||
}
|
||||
|
||||
const baseName = input.substring(0, hashIndex)
|
||||
const linePart = input.substring(hashIndex + 1)
|
||||
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
|
||||
|
||||
if (!lineMatch) {
|
||||
return { baseQuery: baseName }
|
||||
}
|
||||
|
||||
const startLine = Number(lineMatch[1])
|
||||
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
|
||||
|
||||
return {
|
||||
lineRange: {
|
||||
baseName,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
baseQuery: baseName,
|
||||
}
|
||||
}
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
|
|
@ -275,9 +244,9 @@ export function Autocomplete(props: {
|
|||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
const { baseQuery } = extractLineRange(search())
|
||||
const slash = baseQuery.indexOf("/")
|
||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||
const base = parseFileLineRange(search()).base
|
||||
const slash = base.indexOf("/")
|
||||
const alias = slash === -1 ? base : base.slice(0, slash)
|
||||
return references().find((item) => !item.hidden && item.name === alias)
|
||||
})
|
||||
|
||||
|
|
@ -312,11 +281,11 @@ export function Autocomplete(props: {
|
|||
async (input) => {
|
||||
if (!input.visible || input.visible === "/") return { options: [], failed: false }
|
||||
if (referenceMatch()) return { options: [], failed: false }
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
const { lineRange, base } = parseFileLineRange(input.query ?? "")
|
||||
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: baseQuery,
|
||||
query: base,
|
||||
limit: 20,
|
||||
location: {
|
||||
directory: input.location?.directory,
|
||||
|
|
@ -500,9 +469,9 @@ export function Autocomplete(props: {
|
|||
}
|
||||
|
||||
const fuzziedNonFiles = fuzzysort
|
||||
.go(removeLineRange(searchValue), nonFileOptions, {
|
||||
.go(stripFileLineRange(searchValue), nonFileOptions, {
|
||||
keys: [
|
||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
(obj) => stripFileLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
// Match description for slash commands only; for "@" it surfaced unrelated items.
|
||||
...(store.visible === "/" ? ["description" as const] : []),
|
||||
(obj) => obj.aliases?.join(" ") ?? "",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../.
|
|||
import { normalizePromptContent, openEditor } from "../../editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { promptOffsetWidth } from "../../prompt/display"
|
||||
import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention"
|
||||
import { parseSlashHead } from "../../prompt/parse"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||
|
|
@ -137,15 +139,15 @@ function formatEditorContext(selection: EditorSelection) {
|
|||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
|
||||
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
if (!input.startsWith("/")) return
|
||||
const separator = input.search(/\s/)
|
||||
const name = input.slice(1, separator === -1 ? undefined : separator)
|
||||
const head = parseSlashHead(input, /\s/)
|
||||
if (!head) return
|
||||
const command = commands.find(
|
||||
(command) =>
|
||||
command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true),
|
||||
command.slash?.arguments &&
|
||||
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
|
||||
)
|
||||
if (!command) return
|
||||
return { command, input: separator === -1 ? "" : input.slice(separator + 1) }
|
||||
return { command, input: head.arguments }
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
|
|
@ -493,13 +495,8 @@ export function Prompt(props: PromptProps) {
|
|||
run: async () => {
|
||||
dialog.clear()
|
||||
|
||||
// replace summarized text parts with the actual text
|
||||
const text = store.prompt.pasted.reduce(
|
||||
(result, part) => result.replace(part.source.text, part.text),
|
||||
store.prompt.text,
|
||||
)
|
||||
|
||||
const value = text
|
||||
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
|
||||
const value = editorPrompt.text
|
||||
const content = await openEditor({
|
||||
renderer,
|
||||
value,
|
||||
|
|
@ -513,20 +510,8 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
input.setText(normalized)
|
||||
|
||||
// Update attachment positions and drop virtual text deleted in the editor.
|
||||
// this handles a case where the user edits the text in the editor
|
||||
// such that the virtual text moves around or is deleted
|
||||
const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(part: Part) => {
|
||||
if (!part.mention?.text) return part
|
||||
const start = normalized.indexOf(part.mention.text)
|
||||
if (start === -1) return
|
||||
return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } }
|
||||
}
|
||||
|
||||
setStore("prompt", {
|
||||
text: normalized,
|
||||
files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined),
|
||||
agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined),
|
||||
...realignPromptInputMentions(normalized, editorPrompt),
|
||||
pasted: [],
|
||||
})
|
||||
restoreExtmarksFromPrompt(store.prompt)
|
||||
|
|
@ -1122,7 +1107,7 @@ export function Prompt(props: PromptProps) {
|
|||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session.model.variant !== variant
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import { useArgs } from "./args"
|
|||
import { useClient } from "./client"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import {
|
||||
createModelPreferenceRepository,
|
||||
cycleModelVariant,
|
||||
modelPreferenceKey,
|
||||
normalizeModelVariant,
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
|
|
@ -34,13 +42,13 @@ export function parseModel(model: string) {
|
|||
}
|
||||
|
||||
export function recentModels(
|
||||
model: { providerID: string; modelID: string },
|
||||
recent: { providerID: string; modelID: string }[],
|
||||
model: ModelPreferenceModel,
|
||||
recent: ModelPreferenceModel[],
|
||||
) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
const key = `${item.providerID}/${item.modelID}`
|
||||
const key = modelPreferenceKey(item)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
|
|
@ -62,13 +70,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const event = useEvent()
|
||||
const permission = usePermission()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
function isModelValid(model: ModelPreferenceModel) {
|
||||
return !!data.location.model
|
||||
.list()
|
||||
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
|
|
@ -144,25 +152,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const agent = createAgent()
|
||||
|
||||
function createModel() {
|
||||
const [modelStore, setModelStore] = createStore<{
|
||||
ready: boolean
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
favorite: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
variant: Record<string, string | undefined>
|
||||
}>({
|
||||
const [modelStore, setModelStore] = createStore<
|
||||
ModelPreference & {
|
||||
ready: boolean
|
||||
model: Record<string, ModelPreferenceModel>
|
||||
}
|
||||
>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
|
|
@ -170,7 +165,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
variant: {},
|
||||
})
|
||||
|
||||
const filePath = path.join(paths.state, "model.json")
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
|
@ -181,21 +176,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return
|
||||
}
|
||||
state.pending = false
|
||||
void writeJsonAtomic(filePath, {
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
void repository
|
||||
.patch({
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((x) => {
|
||||
if (!x || typeof x !== "object") return
|
||||
const value = x as Record<string, unknown>
|
||||
if (Array.isArray(value.recent)) setModelStore("recent", value.recent)
|
||||
if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite)
|
||||
if (typeof value.variant === "object" && value.variant !== null)
|
||||
setModelStore("variant", value.variant as Record<string, string | undefined>)
|
||||
repository
|
||||
.load()
|
||||
.then((value) => {
|
||||
setModelStore("recent", value.recent)
|
||||
setModelStore("favorite", value.favorite)
|
||||
setModelStore("variant", value.variant)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
|
|
@ -360,14 +355,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
selected() {
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key] ?? "default"
|
||||
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (v !== "default" && this.list().includes(v)) return v
|
||||
return "default"
|
||||
if (v && this.list().includes(v)) return v
|
||||
return undefined
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
|
|
@ -380,24 +373,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
if (!m) return
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
setModelStore("variant", key, value ?? "default")
|
||||
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
save()
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const current = this.current()
|
||||
if (!current) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
this.set(cycleModelVariant(this.current(), variants))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import path from "path"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { formatPath } from "../util/path-format"
|
||||
import { useLocation } from "./location"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
|
|
@ -8,17 +7,6 @@ export function usePathFormatter() {
|
|||
const location = useLocation()
|
||||
return {
|
||||
path: () => location.current?.directory || paths.cwd,
|
||||
format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home),
|
||||
format: (input?: string) => formatPath(input, { base: location.current?.directory || paths.cwd, home: paths.home }),
|
||||
}
|
||||
}
|
||||
|
||||
function formatPath(input: string | undefined, base: string, home: string) {
|
||||
if (typeof input !== "string" || !input) return ""
|
||||
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(base, input)
|
||||
const relative = path.relative(base, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||
return abbreviateHome(absolute, home)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
124
packages/tui/src/model-preference.ts
Normal file
124
packages/tui/src/model-preference.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { readJson, writeJsonAtomic } from "./util/persistence"
|
||||
import { isRecord } from "./util/record"
|
||||
|
||||
export type ModelPreferenceModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export type ModelPreference = {
|
||||
recent: ModelPreferenceModel[]
|
||||
favorite: ModelPreferenceModel[]
|
||||
variant: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export type ModelPreferenceDocument = Record<string, unknown> & ModelPreference
|
||||
|
||||
function models(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap((item): ModelPreferenceModel[] => {
|
||||
if (!isRecord(item)) return []
|
||||
if (typeof item.providerID !== "string" || item.providerID.length === 0) return []
|
||||
if (typeof item.modelID !== "string" || item.modelID.length === 0) return []
|
||||
return [{ providerID: item.providerID, modelID: item.modelID }]
|
||||
})
|
||||
}
|
||||
|
||||
function variants(value: unknown) {
|
||||
if (!isRecord(value)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
if (key.length === 0 || typeof item !== "string" || item.length === 0) return []
|
||||
const variant = normalizeModelVariant(item)
|
||||
return variant === undefined ? [] : ([[key, variant]] as const)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeModelVariant(value: string | undefined) {
|
||||
return value === "default" ? undefined : value
|
||||
}
|
||||
|
||||
export function modelPreferenceKey(model: ModelPreferenceModel) {
|
||||
return `${model.providerID}/${model.modelID}`
|
||||
}
|
||||
|
||||
export function cycleModelVariant(current: string | undefined, variants: string[]) {
|
||||
const named = variants.filter((variant) => variant !== "default")
|
||||
if (named.length === 0) return undefined
|
||||
const value = normalizeModelVariant(current)
|
||||
if (value === undefined) return named[0]
|
||||
const index = named.indexOf(value)
|
||||
if (index === -1 || index === named.length - 1) return undefined
|
||||
return named[index + 1]
|
||||
}
|
||||
|
||||
export function decodeModelPreference(value: unknown): ModelPreferenceDocument {
|
||||
const root = isRecord(value) ? value : {}
|
||||
return {
|
||||
...root,
|
||||
recent: models(root.recent),
|
||||
favorite: models(root.favorite),
|
||||
variant: variants(root.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function preference(value: ModelPreferenceDocument): ModelPreference {
|
||||
return {
|
||||
recent: value.recent,
|
||||
favorite: value.favorite,
|
||||
variant: value.variant,
|
||||
}
|
||||
}
|
||||
|
||||
function patch(value: Partial<ModelPreference>) {
|
||||
return {
|
||||
...(value.recent === undefined ? {} : { recent: models(value.recent) }),
|
||||
...(value.favorite === undefined ? {} : { favorite: models(value.favorite) }),
|
||||
...(value.variant === undefined ? {} : { variant: variants(value.variant) }),
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelPreferenceRepository(filePath: string) {
|
||||
const state = {
|
||||
pending: Promise.resolve(),
|
||||
}
|
||||
const read = () =>
|
||||
readJson<unknown>(filePath)
|
||||
.then(decodeModelPreference)
|
||||
.catch(() => decodeModelPreference(undefined))
|
||||
|
||||
function update(change: (current: ModelPreference) => Partial<ModelPreference>) {
|
||||
const result = state.pending.then(async () => {
|
||||
const current = await read()
|
||||
const next = { ...current, ...patch(change(preference(current))) }
|
||||
await writeJsonAtomic(filePath, next)
|
||||
})
|
||||
state.pending = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
function load() {
|
||||
return state.pending.then(read).then(preference)
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
patch(value: Partial<ModelPreference>) {
|
||||
return update(() => value)
|
||||
},
|
||||
async resolveVariant(model: ModelPreferenceModel) {
|
||||
return (await load()).variant[modelPreferenceKey(model)]
|
||||
},
|
||||
saveVariant(model: ModelPreferenceModel, value: string | undefined) {
|
||||
const key = modelPreferenceKey(model)
|
||||
const next = normalizeModelVariant(value)
|
||||
return update((current) => {
|
||||
const variant = { ...current.variant }
|
||||
if (next === undefined) delete variant[key]
|
||||
if (next !== undefined) variant[key] = next
|
||||
return { variant }
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/tui/src/prompt/codec.ts
Normal file
20
packages/tui/src/prompt/codec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Prompt, PromptInput } from "@opencode-ai/schema"
|
||||
import type { Types } from "effect"
|
||||
|
||||
export type EditablePromptInput = Types.DeepMutable<PromptInput.Prompt>
|
||||
|
||||
export function projectedPromptInput(input: Pick<Prompt, "text" | "files" | "agents">): EditablePromptInput {
|
||||
return {
|
||||
text: input.text,
|
||||
files: input.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: input.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
139
packages/tui/src/prompt/mention.ts
Normal file
139
packages/tui/src/prompt/mention.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { PromptInput, PromptMention } from "@opencode-ai/schema"
|
||||
import type { EditablePromptInput } from "./codec"
|
||||
import { promptOffsetWidth } from "./display"
|
||||
import { expandTrackedPastedText } from "./part"
|
||||
|
||||
type TextRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type MentionItem = {
|
||||
index: number
|
||||
mention: PromptMention
|
||||
}
|
||||
|
||||
type CandidateRange = TextRange & {
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function realignPromptMentions(
|
||||
content: string,
|
||||
mentions: readonly (PromptMention | undefined)[],
|
||||
): Array<PromptMention | undefined> {
|
||||
const protectedRanges: TextRange[] = []
|
||||
const aligned = mentions.map((mention) => (mention && !mention.text ? { ...mention } : undefined))
|
||||
const groups = mentions.reduce((result, mention, index) => {
|
||||
if (!mention?.text) return result
|
||||
const group = result.get(mention.text) ?? []
|
||||
group.push({ mention, index })
|
||||
result.set(mention.text, group)
|
||||
return result
|
||||
}, new Map<string, MentionItem[]>())
|
||||
|
||||
for (const [text, items] of [...groups.entries()].sort(
|
||||
([left], [right]) => right.length - left.length || left.localeCompare(right),
|
||||
)) {
|
||||
const candidates = mentionRanges(content, text)
|
||||
const available = candidates.filter(
|
||||
(candidate) => !protectedRanges.some((range) => overlaps(candidate, range)),
|
||||
)
|
||||
for (const [item, candidate] of assignMentions(items, available)) {
|
||||
aligned[item.index] = {
|
||||
text,
|
||||
start: candidate.offset,
|
||||
end: candidate.offset + promptOffsetWidth(text),
|
||||
}
|
||||
}
|
||||
protectedRanges.push(...candidates)
|
||||
}
|
||||
|
||||
return aligned
|
||||
}
|
||||
|
||||
export function realignPromptInputMentions(content: string, input: PromptInput.Prompt): EditablePromptInput {
|
||||
const files = input.files ?? []
|
||||
const agents = input.agents ?? []
|
||||
const mentions = realignPromptMentions(content, [
|
||||
...files.map((file) => file.mention),
|
||||
...agents.map((agent) => agent.mention),
|
||||
])
|
||||
const align = <T extends { mention?: PromptMention }>(items: readonly T[] | undefined, offset = 0) =>
|
||||
items?.flatMap((item, index) => {
|
||||
if (!item.mention?.text) return [{ ...item, mention: item.mention ? { ...item.mention } : undefined }]
|
||||
const mention = mentions[offset + index]
|
||||
return mention ? [{ ...item, mention }] : []
|
||||
})
|
||||
|
||||
return {
|
||||
text: content,
|
||||
files: align(input.files),
|
||||
agents: align(input.agents, files.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandPromptInputPastedText(
|
||||
input: PromptInput.Prompt,
|
||||
pasted: readonly { text: string; source: { start: number; end: number } }[],
|
||||
): EditablePromptInput {
|
||||
const ranges = pasted.map((part) => ({ ...part.source, text: part.text }))
|
||||
const shift = (mention: PromptMention | undefined) => {
|
||||
if (!mention) return
|
||||
const offset = ranges.reduce(
|
||||
(total, range) =>
|
||||
range.end <= mention.start ? total + promptOffsetWidth(range.text) - (range.end - range.start) : total,
|
||||
0,
|
||||
)
|
||||
return { ...mention, start: mention.start + offset, end: mention.end + offset }
|
||||
}
|
||||
|
||||
return {
|
||||
text: expandTrackedPastedText(input.text, ranges),
|
||||
files: input.files?.map((file) => ({ ...file, mention: shift(file.mention) })),
|
||||
agents: input.agents?.map((agent) => ({ ...agent, mention: shift(agent.mention) })),
|
||||
}
|
||||
}
|
||||
|
||||
function mentionRanges(content: string, text: string): CandidateRange[] {
|
||||
const ranges: CandidateRange[] = []
|
||||
let searchFrom = 0
|
||||
while (true) {
|
||||
const start = content.indexOf(text, searchFrom)
|
||||
if (start === -1) return ranges
|
||||
ranges.push({ start, end: start + text.length, offset: promptOffsetWidth(content.slice(0, start)) })
|
||||
searchFrom = start + text.length
|
||||
}
|
||||
}
|
||||
|
||||
function assignMentions(items: MentionItem[], candidates: CandidateRange[]) {
|
||||
const ordered = items.toSorted((left, right) => left.mention.start - right.mention.start || left.index - right.index)
|
||||
const memo = new Map<string, { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> }>()
|
||||
|
||||
function solve(item: number, candidate: number): { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> } {
|
||||
if (item >= ordered.length || candidate >= candidates.length) return { matches: 0, cost: 0, pairs: [] }
|
||||
const key = `${item}:${candidate}`
|
||||
const cached = memo.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const tail = solve(item + 1, candidate + 1)
|
||||
const current = ordered[item]!
|
||||
const range = candidates[candidate]!
|
||||
const matched = {
|
||||
matches: tail.matches + 1,
|
||||
cost: tail.cost + Math.abs(range.offset - current.mention.start),
|
||||
pairs: [[current, range] as [MentionItem, CandidateRange], ...tail.pairs],
|
||||
}
|
||||
const result = [matched, solve(item + 1, candidate), solve(item, candidate + 1)].reduce((best, next) => {
|
||||
if (next.matches !== best.matches) return next.matches > best.matches ? next : best
|
||||
return next.cost < best.cost ? next : best
|
||||
})
|
||||
memo.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return solve(0, 0).pairs
|
||||
}
|
||||
|
||||
function overlaps(left: TextRange, right: TextRange) {
|
||||
return left.start < right.end && left.end > right.start
|
||||
}
|
||||
26
packages/tui/src/prompt/parse.ts
Normal file
26
packages/tui/src/prompt/parse.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export function parseFileLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) return { base: input }
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const match = input.slice(hash + 1).match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) return { base }
|
||||
|
||||
const startLine = Number(match[1])
|
||||
const endLine = match[2] && startLine < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, lineRange: { startLine, endLine } }
|
||||
}
|
||||
|
||||
export function stripFileLineRange(input: string) {
|
||||
return parseFileLineRange(input).base
|
||||
}
|
||||
|
||||
export function parseSlashHead(text: string, separator = /[ \t\n]/) {
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const end = text.slice(1).search(separator)
|
||||
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 }
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useClient } from "../../context/client"
|
||||
|
|
@ -9,6 +8,7 @@ import { useDialog } from "../../ui/dialog"
|
|||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
|
||||
export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) {
|
||||
const data = useData()
|
||||
|
|
@ -26,20 +26,15 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov
|
|||
})
|
||||
if (!result) return dialog.clear()
|
||||
const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined
|
||||
const prompt = message?.type === "user" ? projectedPromptInput(message) : undefined
|
||||
route.navigate({
|
||||
sessionID: result.id,
|
||||
type: "session",
|
||||
prompt:
|
||||
message?.type === "user"
|
||||
prompt
|
||||
? {
|
||||
text: message.text,
|
||||
files: message.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: structuredClone(unwrap(message.agents ?? [])),
|
||||
...prompt,
|
||||
agents: prompt.agents ?? [],
|
||||
pasted: [],
|
||||
}
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useClient } from "../../context/client"
|
|||
import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
|
|
@ -31,17 +32,7 @@ export function DialogMessage(props: {
|
|||
const value = message()
|
||||
if (value?.type === "user") {
|
||||
props.setPrompt?.({
|
||||
text: value.text,
|
||||
files: value.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: value.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
...projectedPromptInput(value),
|
||||
pasted: [],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,128 +11,27 @@ import { useClipboard } from "../../context/clipboard"
|
|||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
isFormAnswerField,
|
||||
} from "../../util/form"
|
||||
import type { FormAnswerField } from "../../util/form"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
type Field = Exclude<FormField, { type: "external" }>
|
||||
|
||||
function isField(field: FormField): field is Field {
|
||||
return field.type !== "external"
|
||||
}
|
||||
|
||||
function fieldLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
function truncate(label: string, max: number) {
|
||||
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
|
||||
}
|
||||
|
||||
function validateText(field: Field, text: string): string | undefined {
|
||||
if (field.type !== "string") return undefined
|
||||
if (field.minLength !== undefined && text.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && text.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(text)) return `Must match pattern: ${field.pattern}`
|
||||
} catch {
|
||||
return `Invalid pattern: ${field.pattern}`
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address"
|
||||
if (field.format === "uri") {
|
||||
try {
|
||||
new URL(text)
|
||||
} catch {
|
||||
return "Expected a URL"
|
||||
}
|
||||
}
|
||||
if (field.format === "date") {
|
||||
const date = new Date(`${text}T00:00:00.000Z`)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text)
|
||||
return "Expected a date (YYYY-MM-DD)"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function validateSelection(field: Field, value: FormValue | undefined): string | undefined {
|
||||
if (field.type !== "multiselect" || value === undefined) return undefined
|
||||
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}`
|
||||
return undefined
|
||||
}
|
||||
|
||||
function validateValue(field: Field, 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"
|
||||
const invalid = validateText(field, value)
|
||||
if (invalid) return invalid
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
||||
return "Select an available option"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
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 undefined
|
||||
}
|
||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||
const invalid = validateSelection(field, value)
|
||||
if (invalid) return invalid
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
!field.custom &&
|
||||
value.some((item) => !field.options.some((option) => option.value === item))
|
||||
) {
|
||||
return "Select only available options"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
]
|
||||
if (field.type === "multiselect" || (field.type === "string" && field.options))
|
||||
return (field.options ?? []).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
return []
|
||||
}
|
||||
|
||||
function selectedRow(field: Field | undefined, value: FormValue | undefined) {
|
||||
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||
const rows = fieldRows(field)
|
||||
const index = rows.findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && field.type === "string" && field.options && field.custom) return rows.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function display(field: Field, value: FormValue | undefined) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
fieldRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
if (Array.isArray(value)) return value.length === 0 ? "(none)" : value.map(label).join(", ")
|
||||
return label(value)
|
||||
}
|
||||
|
||||
function requestOptions(form: FormWithLocation) {
|
||||
if (form.sessionID !== "global" || !form.location) return undefined
|
||||
return {
|
||||
|
|
@ -151,23 +50,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const keymap = Keymap.use()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const configuredFields = props.form.fields.filter(isField)
|
||||
const configuredFields = props.form.fields.filter(isFormAnswerField)
|
||||
const initial = formInitialValues(props.form.fields)
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: Object.fromEntries(
|
||||
configuredFields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
|
||||
) as Record<string, FormValue | undefined>,
|
||||
custom: Object.fromEntries(
|
||||
configuredFields.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>,
|
||||
answers: initial.answers,
|
||||
custom: initial.custom,
|
||||
externalReady: {} as Record<string, boolean>,
|
||||
selected: selectedRow(configuredFields[0], configuredFields[0]?.default),
|
||||
selected: formSelected(configuredFields[0], configuredFields[0]?.default),
|
||||
editing: false,
|
||||
error: "",
|
||||
})
|
||||
|
|
@ -202,7 +94,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
})
|
||||
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
||||
const tabbed = createMemo(() => {
|
||||
const width = fields().reduce((sum, item) => sum + truncate(fieldLabel(item), 24).length + 3, "Confirm".length + 3)
|
||||
const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Confirm".length + 3)
|
||||
return width <= dimensions().width - 8
|
||||
})
|
||||
const answered = createMemo(
|
||||
|
|
@ -215,7 +107,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const field = createMemo(() => fields()[store.tab])
|
||||
const answerField = createMemo(() => {
|
||||
const current = field()
|
||||
return current && isField(current) ? current : undefined
|
||||
return current && isFormAnswerField(current) ? current : undefined
|
||||
})
|
||||
const externalField = createMemo(() => {
|
||||
const current = field()
|
||||
|
|
@ -225,7 +117,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const rows = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (!current) return []
|
||||
const configured = fieldRows(current)
|
||||
const configured = formRows(current)
|
||||
const value = store.answers[current.key]
|
||||
if (current.type !== "multiselect" || !Array.isArray(value)) return configured
|
||||
const known = new Set(configured.map((row) => row.value))
|
||||
|
|
@ -236,17 +128,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
})
|
||||
const textual = createMemo(() => {
|
||||
if (confirm()) return false
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
if (current.type === "number" || current.type === "integer") return true
|
||||
return current.type === "string" && current.options === undefined
|
||||
return formTextual(answerField())
|
||||
})
|
||||
const custom = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
if (current.type === "string" && current.options !== undefined) return current.custom === true
|
||||
if (current.type === "multiselect") return current.custom === true
|
||||
return false
|
||||
return formCustom(answerField())
|
||||
})
|
||||
const multi = createMemo(() => answerField()?.type === "multiselect")
|
||||
const actionLabel = createMemo(() => {
|
||||
|
|
@ -293,7 +178,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: Field, value: FormValue) {
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
|
|
@ -316,7 +201,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function pick(value: FormValue, customValue?: string) {
|
||||
const current = answerField()
|
||||
if (!current) return
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return
|
||||
|
|
@ -333,19 +218,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function toggle(value: string) {
|
||||
const current = answerField()
|
||||
if (!current) return
|
||||
const existing = store.answers[current.key]
|
||||
const list = Array.isArray(existing) ? [...existing] : []
|
||||
const index = list.indexOf(value)
|
||||
if (index === -1) list.push(value)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
answer(current.key, list)
|
||||
answer(current.key, formToggleMultiselect(store.answers[current.key], value))
|
||||
}
|
||||
|
||||
function validateCurrent() {
|
||||
if (confirm()) return true
|
||||
const current = answerField()
|
||||
if (!current) return true
|
||||
const invalid = validateValue(current, store.answers[current.key])
|
||||
const invalid = formValidateValue(current, store.answers[current.key])
|
||||
if (!invalid) return true
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -355,7 +235,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
if (!confirm() && index > store.tab && !validateCurrent()) return
|
||||
const next = fields()[index]
|
||||
setStore("tab", index)
|
||||
setStore("selected", next && isField(next) ? selectedRow(next, store.answers[next.key]) : 0)
|
||||
setStore("selected", next && isFormAnswerField(next) ? formSelected(next, store.answers[next.key]) : 0)
|
||||
setStore("editing", false)
|
||||
setStore("error", "")
|
||||
}
|
||||
|
|
@ -393,7 +273,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const existing = store.answers[current.key]
|
||||
const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : []
|
||||
const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -406,7 +286,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
|
||||
if (isTextual && (current.type === "number" || current.type === "integer")) {
|
||||
const value = Number(text)
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -415,7 +295,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
}
|
||||
|
||||
if (isTextual && current.type === "string") {
|
||||
const invalid = validateValue(current, text)
|
||||
const invalid = formValidateValue(current, text)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -424,19 +304,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
}
|
||||
|
||||
if (!isTextual && isMulti) {
|
||||
const previous = store.custom[current.key]
|
||||
const existing = store.answers[current.key]
|
||||
const values = Array.isArray(existing) ? [...existing] : []
|
||||
if (previous) {
|
||||
const index = values.indexOf(previous)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
}
|
||||
if (!values.includes(text)) values.push(text)
|
||||
answer(current.key, values)
|
||||
answer(current.key, formSetMultiselectCustom(store.answers[current.key], store.custom[current.key], text))
|
||||
}
|
||||
|
||||
if (!isTextual && !isMulti) {
|
||||
const invalid = validateValue(current, text)
|
||||
const invalid = formValidateValue(current, text)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -518,14 +390,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function submit() {
|
||||
const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true)
|
||||
if (unacknowledged) {
|
||||
setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`)
|
||||
setStore("error", `External action must be acknowledged: ${formLabel(unacknowledged)}`)
|
||||
return
|
||||
}
|
||||
const invalid = fields()
|
||||
.filter(isField)
|
||||
.find((field) => validateValue(field, store.answers[field.key]))
|
||||
.filter(isFormAnswerField)
|
||||
.find((field) => formValidateValue(field, store.answers[field.key]))
|
||||
if (invalid) {
|
||||
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
|
|
@ -812,7 +684,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
: themeV2.text.subdued()
|
||||
}
|
||||
>
|
||||
{truncate(fieldLabel(item), 24)}
|
||||
{truncate(formLabel(item), 24)}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -877,7 +749,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text()}>
|
||||
{answerField()!.description ?? fieldLabel(answerField()!)}
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
|
|
@ -893,7 +765,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
|
||||
initialValue={
|
||||
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
||||
}
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
minHeight={1}
|
||||
|
|
@ -1049,7 +923,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
|
|
@ -1063,14 +937,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
</box>
|
||||
)
|
||||
}
|
||||
const value = () => display(item, store.answers[item.key])
|
||||
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
|
||||
const answered = () => store.answers[item.key] !== undefined
|
||||
const missing = () => !answered() && item.required === true
|
||||
const invalid = () => validateValue(item, store.answers[item.key])
|
||||
const invalid = () => formValidateValue(item, store.answers[item.key])
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ import type {
|
|||
import { useLocal } from "../../context/local"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../util/tool-display"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
|
|
@ -55,6 +61,7 @@ import { errorMessage } from "../../util/error"
|
|||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
|
|
@ -511,17 +518,7 @@ export function Session() {
|
|||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt?.set({
|
||||
text: message.text,
|
||||
files: message.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: message.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
})
|
||||
dialog.clear()
|
||||
|
|
@ -2036,7 +2033,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
return props.part.state.status === "streaming" ? {} : props.part.state.structured
|
||||
return toolDisplayMetadata(props.part.state)
|
||||
},
|
||||
get input() {
|
||||
return typeof props.part.state.input === "string" ? {} : props.part.state.input
|
||||
|
|
@ -2568,8 +2565,8 @@ function Glob(props: ToolProps) {
|
|||
<InlineTool icon="✱" pending="Finding files..." complete={stringValue(props.input.pattern)} part={props.part}>
|
||||
Glob "{stringValue(props.input.pattern)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.count)}>
|
||||
({numberValue(props.metadata.count)} {numberValue(props.metadata.count) === 1 ? "match" : "matches"})
|
||||
<Show when={finiteNumber(props.metadata.count)}>
|
||||
({finiteNumber(props.metadata.count)} {finiteNumber(props.metadata.count) === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
|
|
@ -2615,8 +2612,8 @@ function Grep(props: ToolProps) {
|
|||
<InlineTool icon="✱" pending="Searching content..." complete={stringValue(props.input.pattern)} part={props.part}>
|
||||
Grep "{stringValue(props.input.pattern)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.matches)}>
|
||||
({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"})
|
||||
<Show when={finiteNumber(props.metadata.matches)}>
|
||||
({finiteNumber(props.metadata.matches)} {finiteNumber(props.metadata.matches) === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
|
|
@ -2634,7 +2631,7 @@ function WebSearch(props: ToolProps) {
|
|||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
|
||||
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
|
||||
<Show when={numberValue(props.metadata.numResults)}>({numberValue(props.metadata.numResults)} results)</Show>
|
||||
<Show when={finiteNumber(props.metadata.numResults)}>({finiteNumber(props.metadata.numResults)} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -2708,7 +2705,7 @@ function Execute(props: ToolProps) {
|
|||
const content = createMemo(() => {
|
||||
const lines = ["execute"]
|
||||
for (const call of calls()) {
|
||||
const args = input(call.input ?? {})
|
||||
const args = primitiveInputSummary(call.input ?? {})
|
||||
lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
|
|
@ -2991,23 +2988,10 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function input(input: Record<string, unknown>, omit?: string[]): string {
|
||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit?.includes(key)) return false
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
})
|
||||
if (primitives.length === 0) return ""
|
||||
return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const toolDisplays = new Set([
|
||||
"shell",
|
||||
"glob",
|
||||
|
|
@ -3025,9 +3009,7 @@ const toolDisplays = new Set([
|
|||
])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
|
||||
// them with the renamed views.
|
||||
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
|
|
@ -3073,8 +3055,8 @@ export function parseApplyPatchFiles(value: unknown) {
|
|||
const relativePath = stringValue(file.file) ?? stringValue(file.relativePath)
|
||||
const filePath = stringValue(file.filePath) ?? relativePath
|
||||
const patch = stringValue(file.patch)
|
||||
const additions = numberValue(file.additions)
|
||||
const deletions = numberValue(file.deletions)
|
||||
const additions = finiteNumber(file.additions)
|
||||
const deletions = finiteNumber(file.deletions)
|
||||
if (
|
||||
!type ||
|
||||
!relativePath ||
|
||||
|
|
@ -3110,8 +3092,8 @@ export function parseDiagnostics(value: unknown, filePath: string) {
|
|||
.flatMap((item) => {
|
||||
const diagnostic = recordValue(item)
|
||||
const start = recordValue(recordValue(diagnostic?.range)?.start)
|
||||
const line = numberValue(start?.line)
|
||||
const character = numberValue(start?.character)
|
||||
const line = finiteNumber(start?.line)
|
||||
const character = finiteNumber(start?.character)
|
||||
const message = stringValue(diagnostic?.message)
|
||||
if (diagnostic?.severity !== 1 || line === undefined || character === undefined || !message) return []
|
||||
return [{ range: { start: { line, character } }, message }]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { dirname } from "node:path"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
|
|
@ -9,8 +8,7 @@ import { useClient } from "../../context/client"
|
|||
import { SplitBorder } from "../../ui/border"
|
||||
import { useData } from "../../context/data"
|
||||
import { filetype } from "../../util/filetype"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../../util/permission"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
|
|
@ -19,20 +17,15 @@ import { SimulationSemantics } from "../../simulation/semantics"
|
|||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const syntax = themeState.syntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => {
|
||||
return props.request.resources[0] ?? ""
|
||||
})
|
||||
const diff = createMemo(() => {
|
||||
const value = props.request.metadata?.diff
|
||||
return typeof value === "string" ? value : ""
|
||||
})
|
||||
const filepath = createMemo(() => props.file ?? "")
|
||||
const diff = createMemo(() => props.diff ?? "")
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffView = config.diffs?.view
|
||||
|
|
@ -114,27 +107,6 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TextBody(props: { title: string; description?: string; icon?: string }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<Show when={props.icon}>
|
||||
<text fg={themeV2.text.subdued()} flexShrink={0}>
|
||||
{props.icon}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>{props.title}</text>
|
||||
</box>
|
||||
<Show when={props.description}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{props.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
|
|
@ -144,14 +116,16 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
const pathFormatter = usePathFormatter()
|
||||
const session = createMemo(() => data.session.get(props.request.sessionID))
|
||||
|
||||
const input = createMemo(() => {
|
||||
const source = createMemo(() => {
|
||||
const tool = props.request.source
|
||||
if (!tool) return {}
|
||||
if (!tool) return { input: undefined, structured: undefined }
|
||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return {}
|
||||
if (message?.type !== "assistant") return { input: undefined, structured: undefined }
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
|
||||
return {}
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") {
|
||||
return { input: part.state.input, structured: part.state.structured }
|
||||
}
|
||||
return { input: undefined, structured: undefined }
|
||||
})
|
||||
|
||||
const { themeV2 } = useTheme()
|
||||
|
|
@ -164,30 +138,13 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
semanticLabel={`Always allow ${props.request.action}`}
|
||||
instance={props.request.id}
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
|
||||
<TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
This will allow the following patterns until OpenCode is restarted
|
||||
</text>
|
||||
<box>
|
||||
<For each={props.request.save ?? []}>
|
||||
{(pattern) => (
|
||||
<text fg={themeV2.text()}>
|
||||
{"- "}
|
||||
{pattern}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? themeV2.text.subdued() : themeV2.text()}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
options={{ confirm: "Confirm", cancel: "Cancel" }}
|
||||
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
|
||||
escapeKey="cancel"
|
||||
onSelect={(option) => {
|
||||
setStore("stage", "permission")
|
||||
|
|
@ -219,198 +176,47 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
</Match>
|
||||
<Match when={store.stage === "permission"}>
|
||||
{(() => {
|
||||
const info = () => {
|
||||
const permission = props.request.action
|
||||
const data = input()
|
||||
|
||||
if (permission === "edit") {
|
||||
const filepath = props.request.resources[0] ?? ""
|
||||
const patch = typeof data.patchText === "string" ? data.patchText : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||
body: <EditBody request={props.request} patch={patch} />,
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "read") {
|
||||
const raw = data.path
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${pathFormatter.format(filePath)}`,
|
||||
body: (
|
||||
<Show when={filePath}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "glob") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Glob "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "grep") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Grep "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "list") {
|
||||
const raw = data.path
|
||||
const dir = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${pathFormatter.format(dir)}`,
|
||||
body: (
|
||||
<Show when={dir}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "shell") {
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
return {
|
||||
body: (
|
||||
<Show when={command}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{"$ " + command}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "subagent" || permission === "task") {
|
||||
const agent =
|
||||
typeof data.agent === "string"
|
||||
? data.agent
|
||||
: typeof data.subagent_type === "string"
|
||||
? data.subagent_type
|
||||
: "Unknown"
|
||||
const desc = typeof data.description === "string" ? data.description : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(agent)} Subagent`,
|
||||
body: (
|
||||
<Show when={desc}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{"◉ " + desc}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "webfetch") {
|
||||
const url = typeof data.url === "string" ? data.url : ""
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
body: (
|
||||
<Show when={url}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"URL: " + url}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "websearch") {
|
||||
const query = typeof data.query === "string" ? data.query : ""
|
||||
return {
|
||||
icon: "◈",
|
||||
title: `${webSearchProviderLabel(data.provider)} "${query}"`,
|
||||
body: (
|
||||
<Show when={query}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Query: " + query}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "external_directory") {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.resources[0]
|
||||
const derived =
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = pathFormatter.format(raw)
|
||||
const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${dir}`,
|
||||
body: (
|
||||
<Show when={patterns.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>Patterns</text>
|
||||
<box>
|
||||
<For each={patterns}>{(p) => <text fg={themeV2.text()}>{"- " + p}</text>}</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>This keeps the session running despite repeated failures.</text>
|
||||
const current = permissionPresentation(
|
||||
{
|
||||
action: props.request.action,
|
||||
resources: props.request.resources,
|
||||
metadata: props.request.metadata,
|
||||
input: source().input,
|
||||
structured: source().structured,
|
||||
},
|
||||
pathFormatter.format,
|
||||
)
|
||||
const presentationBody =
|
||||
props.request.action === "edit" ? (
|
||||
<EditBody file={current.file} diff={current.diff} patch={current.patch} />
|
||||
) : props.request.action === "external_directory" ? (
|
||||
<Show when={current.lines.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>Patterns</text>
|
||||
<box>
|
||||
<For each={current.lines}>{(line) => <text fg={themeV2.text()}>{line}</text>}</For>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${permission}`,
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Tool: " + permission}</text>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const current = info()
|
||||
</Show>
|
||||
) : (
|
||||
<box paddingLeft={1}>
|
||||
<For each={current.lines}>
|
||||
{(line) => (
|
||||
<text
|
||||
fg={
|
||||
props.request.action === "shell" ||
|
||||
props.request.action === "subagent" ||
|
||||
props.request.action === "task"
|
||||
? themeV2.text()
|
||||
: themeV2.text.subdued()
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
|
||||
const header = () => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
|
|
@ -418,7 +224,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
<text fg={themeV2.text.feedback.warning()}>{"△"}</text>
|
||||
<text fg={themeV2.text()}>Permission required</text>
|
||||
</box>
|
||||
<Show when={current.title}>
|
||||
<Show when={props.request.action !== "shell" && current.title}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()} flexShrink={0}>
|
||||
{current.icon}
|
||||
|
|
@ -435,11 +241,15 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||
instance={props.request.id}
|
||||
header={header()}
|
||||
body={current.body}
|
||||
body={presentationBody}
|
||||
options={
|
||||
props.request.save?.length
|
||||
? { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
: { once: "Allow once", reject: "Reject" }
|
||||
? {
|
||||
once: permissionOptionLabel("once"),
|
||||
always: permissionOptionLabel("always"),
|
||||
reject: permissionOptionLabel("reject"),
|
||||
}
|
||||
: { once: permissionOptionLabel("once"), reject: permissionOptionLabel("reject") }
|
||||
}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
|
|
|
|||
|
|
@ -1,10 +1 @@
|
|||
import path from "path"
|
||||
|
||||
export function abbreviateHome(input: string, home: string) {
|
||||
if (!home) return input
|
||||
const relative = path.relative(home, input)
|
||||
if (relative === "") return "~"
|
||||
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return input
|
||||
// Normalize to forward slashes so abbreviated display paths are identical across platforms.
|
||||
return "~/" + relative.split(path.sep).join("/")
|
||||
}
|
||||
export { abbreviateHome } from "./util/path-format"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import { DEFAULT_THEMES, type ColorValue, type Theme, type ThemeColor, type ThemeJson } from "./v1"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
|
||||
|
||||
|
|
@ -79,62 +78,11 @@ export function upsertTheme(name: string, theme: unknown) {
|
|||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
if (c instanceof RGBA) return c
|
||||
if (typeof c === "string") {
|
||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||
|
||||
if (chain.includes(c)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[c] ?? theme.theme[c as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, c])
|
||||
}
|
||||
if (typeof c === "number") {
|
||||
return ansiToRgba(c)
|
||||
}
|
||||
return resolveColor(c[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => {
|
||||
return [key, resolveColor(value as ColorValue)]
|
||||
}),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
// Handle selectedListItemText separately since it's optional
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
if (hasSelectedListItemText) {
|
||||
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
|
||||
} else {
|
||||
// Backward compatibility: if selectedListItemText is not defined, use background color
|
||||
// This preserves the current behavior for all existing themes
|
||||
resolved.selectedListItemText = resolved.background
|
||||
}
|
||||
|
||||
// Handle backgroundMenu - optional with fallback to backgroundElement
|
||||
if (theme.theme.backgroundMenu !== undefined) {
|
||||
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
|
||||
} else {
|
||||
resolved.backgroundMenu = resolved.backgroundElement
|
||||
}
|
||||
|
||||
// Handle thinkingOpacity - optional with default of 0.6
|
||||
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
const resolved = resolveThemeColors(theme, mode)
|
||||
return {
|
||||
...resolved,
|
||||
_hasSelectedListItemText: hasSelectedListItemText,
|
||||
thinkingOpacity,
|
||||
} as Theme
|
||||
...resolved.theme,
|
||||
_hasSelectedListItemText: resolved.hasSelectedListItemText,
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
packages/tui/src/theme/resolve.ts
Normal file
51
packages/tui/src/theme/resolve.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1"
|
||||
|
||||
export function resolveThemeColors(
|
||||
theme: ThemeJson,
|
||||
mode: "dark" | "light",
|
||||
resolveAnsi: (code: number) => RGBA = ansiToRgba,
|
||||
) {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(color: ColorValue, chain: string[] = []): RGBA {
|
||||
if (color instanceof RGBA) return color
|
||||
if (typeof color === "string") {
|
||||
if (color === "transparent" || color === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (color.startsWith("#")) return RGBA.fromHex(color)
|
||||
|
||||
if (chain.includes(color)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, color].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[color] ?? theme.theme[color as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${color}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, color])
|
||||
}
|
||||
if (typeof color === "number") return resolveAnsi(color)
|
||||
return resolveColor(color[mode], chain)
|
||||
}
|
||||
|
||||
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 hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
return {
|
||||
theme: {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText: hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText!)
|
||||
: resolved.background!,
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
} satisfies Omit<Theme, "_hasSelectedListItemText" | "thinkingOpacity">,
|
||||
hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ export type Variant = {
|
|||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useDialog, type DialogContext } from "./dialog"
|
|||
import { Locale } from "../util/locale"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useConfig } from "../config"
|
||||
import { moveSelection, reconcileSelection } from "./select-controller"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
|
|
@ -221,8 +222,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection) {
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next >= 0 && next !== store.selected) setStore("selected", next)
|
||||
const count = flat().length
|
||||
if (count === 0) return
|
||||
const next = reconcileSelection(store.selected, count)
|
||||
if (next !== store.selected) setStore("selected", next)
|
||||
return
|
||||
}
|
||||
if (resetSelection && store.filter.length > 0) {
|
||||
|
|
@ -266,8 +269,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
})
|
||||
return
|
||||
}
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next < 0) return
|
||||
const next = reconcileSelection(store.selected, flat().length)
|
||||
if (flat().length === 0) return
|
||||
setStore("selected", next)
|
||||
selection = flat()[next]
|
||||
},
|
||||
|
|
@ -296,10 +299,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
function move(direction: number) {
|
||||
if (props.locked) return
|
||||
if (flat().length === 0) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next, true)
|
||||
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false, preserve = true) {
|
||||
|
|
|
|||
38
packages/tui/src/ui/select-controller.ts
Normal file
38
packages/tui/src/ui/select-controller.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export function reconcileSelection(selected: number, count: number) {
|
||||
return Math.max(0, Math.min(count - 1, selected))
|
||||
}
|
||||
|
||||
export function moveSelection(selected: number, input: { count: number; delta: number; policy: "clamp" | "wrap" }) {
|
||||
if (input.count <= 0) return 0
|
||||
const next = selected + input.delta
|
||||
if (input.policy === "clamp") return reconcileSelection(next, input.count)
|
||||
if (next < 0) return input.count - 1
|
||||
if (next >= input.count) return 0
|
||||
return next
|
||||
}
|
||||
|
||||
export function revealSelectionOffset(offset: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < offset) return Math.min(max, input.selected)
|
||||
if (input.selected >= offset + input.limit) return Math.min(max, input.selected - input.limit + 1)
|
||||
return Math.max(0, Math.min(max, offset))
|
||||
}
|
||||
|
||||
export function moveSelectionOffset(
|
||||
offset: number,
|
||||
input: { count: number; limit: number; selected: number; direction: -1 | 1 },
|
||||
) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = Math.max(0, Math.min(2, Math.floor((input.limit - 1) / 2)))
|
||||
if (input.direction < 0 && input.selected < offset + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
if (input.direction > 0 && input.selected > offset + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
return Math.max(0, Math.min(max, offset))
|
||||
}
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -272,3 +272,27 @@ test("selects a repopulated option after removing the only option", async () =>
|
|||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the cursor index while options are temporarily empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
|
||||
const select = await mountSelect(tmp.path, options)
|
||||
|
||||
try {
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "second")
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "third")
|
||||
select.replaceOptions([])
|
||||
await select.app.waitForFrame((frame) => frame.includes("No items available"))
|
||||
|
||||
select.replaceOptions(options)
|
||||
await select.app.waitForFrame((frame) => frame.includes("third"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["third"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -98,4 +98,19 @@ describe("run prompt editor helpers", () => {
|
|||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses display offsets when realigning Mini parts", () => {
|
||||
const part = {
|
||||
type: "agent",
|
||||
name: "helper",
|
||||
source: { start: 0, end: 7, value: "@helper" },
|
||||
} satisfies RunPromptPart
|
||||
|
||||
expect(realignEditorPromptParts("中文🙂\n@helper", [part])).toEqual([
|
||||
{
|
||||
...part,
|
||||
source: { start: 7, end: 14, value: "@helper" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES } from "../../src/theme"
|
||||
|
||||
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
|
||||
|
||||
|
|
@ -62,6 +63,18 @@ test("falls back when palette lookup fails", async () => {
|
|||
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
|
||||
})
|
||||
|
||||
test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.theme.primary = 6
|
||||
delete item.theme.selectedListItemText
|
||||
|
||||
const theme = resolveTheme(item, "dark")
|
||||
expect(theme.primary.intent).toBe("indexed")
|
||||
expect(theme.primary.slot).toBe(6)
|
||||
expect(theme.selectedListItemText).toBe(theme.background)
|
||||
expect("_hasSelectedListItemText" in theme).toBe(false)
|
||||
})
|
||||
|
||||
test("returns syntax styles and indexed splash colors", async () => {
|
||||
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeTool, toolOutputText } from "../../src/mini/tool"
|
||||
import { normalizeTool, toolOutputText, toolPath } from "../../src/mini/tool"
|
||||
|
||||
describe("Mini tool presentation", () => {
|
||||
test("uses V2 shell output without the model-facing status", () => {
|
||||
|
|
@ -72,4 +72,9 @@ describe("Mini tool presentation", () => {
|
|||
}),
|
||||
).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } })
|
||||
})
|
||||
|
||||
test("keeps segment-safe contained tool paths relative", () => {
|
||||
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
|
||||
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ const providers: RunProvider[] = [
|
|||
describe("run variant shared", () => {
|
||||
test("prefers cli then session then saved variants", () => {
|
||||
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
|
||||
expect(resolveVariant("default", "high", "low", ["low", "high"])).toBeUndefined()
|
||||
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
|
||||
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
|
||||
})
|
||||
|
||||
test("cycles through variants and back to default", () => {
|
||||
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
|
||||
expect(cycleVariant("default", ["low", "high"])).toBe("low")
|
||||
expect(cycleVariant("low", ["low", "high"])).toBe("high")
|
||||
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
|
||||
expect(cycleVariant(undefined, [])).toBeUndefined()
|
||||
|
|
|
|||
45
packages/tui/test/model-preference.test.ts
Normal file
45
packages/tui/test/model-preference.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { createModelPreferenceRepository, decodeModelPreference } from "../src/model-preference"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("repairs known model preferences and preserves unrelated fields", () => {
|
||||
expect(
|
||||
decodeModelPreference({
|
||||
unrelated: { keep: true },
|
||||
recent: [{ providerID: "openai", modelID: "gpt-5", ignored: true }, null],
|
||||
favorite: "malformed",
|
||||
variant: { "openai/gpt-5": "high", default: "default", invalid: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
unrelated: { keep: true },
|
||||
recent: [{ providerID: "openai", modelID: "gpt-5" }],
|
||||
favorite: [],
|
||||
variant: { "openai/gpt-5": "high" },
|
||||
})
|
||||
})
|
||||
|
||||
test("atomically serializes patches and variant updates", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "model.json")
|
||||
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
|
||||
const repository = createModelPreferenceRepository(file)
|
||||
const openai = { providerID: "openai", modelID: "org/gpt-5" }
|
||||
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
|
||||
|
||||
await Promise.all([
|
||||
repository.patch({ recent: [openai] }),
|
||||
repository.saveVariant(openai, "high"),
|
||||
repository.saveVariant(anthropic, "low"),
|
||||
])
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
unrelated: "keep",
|
||||
recent: [openai],
|
||||
favorite: [],
|
||||
variant: { "openai/org/gpt-5": "high", "anthropic/claude/sonnet": "low" },
|
||||
})
|
||||
|
||||
await repository.saveVariant(openai, "default")
|
||||
expect(await repository.resolveVariant(openai)).toBeUndefined()
|
||||
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
|
||||
})
|
||||
61
packages/tui/test/prompt/codec.test.ts
Normal file
61
packages/tui/test/prompt/codec.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@opencode-ai/schema"
|
||||
import { projectedPromptInput } from "../../src/prompt/codec"
|
||||
|
||||
describe("prompt codec", () => {
|
||||
test("converts projected URI and inline attachments without mutation", () => {
|
||||
const input = {
|
||||
text: "Review @note.ts and image.png with @scan",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||
name: "note.ts",
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
{
|
||||
data: "YWJj",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "screenshot",
|
||||
},
|
||||
],
|
||||
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
|
||||
} satisfies Prompt
|
||||
const before = structuredClone(input)
|
||||
|
||||
const output = projectedPromptInput(input)
|
||||
|
||||
expect(output).toEqual({
|
||||
text: input.text,
|
||||
files: [
|
||||
{
|
||||
uri: "file:///tmp/note.ts",
|
||||
name: "note.ts",
|
||||
description: undefined,
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,YWJj",
|
||||
name: "image.png",
|
||||
description: "screenshot",
|
||||
mention: undefined,
|
||||
},
|
||||
],
|
||||
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
|
||||
})
|
||||
expect(input).toEqual(before)
|
||||
expect(output.files?.[0]?.mention).not.toBe(input.files[0].mention)
|
||||
expect(output.agents?.[0]?.mention).not.toBe(input.agents[0].mention)
|
||||
})
|
||||
|
||||
test("retains empty attachment keys for editable prompt replacement", () => {
|
||||
expect(projectedPromptInput({ text: "plain" })).toEqual({
|
||||
text: "plain",
|
||||
files: undefined,
|
||||
agents: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
91
packages/tui/test/prompt/mention.test.ts
Normal file
91
packages/tui/test/prompt/mention.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
import {
|
||||
expandPromptInputPastedText,
|
||||
realignPromptInputMentions,
|
||||
realignPromptMentions,
|
||||
} from "../../src/prompt/mention"
|
||||
|
||||
test("realigns reordered, duplicate, deleted, and prefix-related mentions", () => {
|
||||
const mentions = [
|
||||
{ start: 0, end: 4, text: "@one" },
|
||||
{ start: 5, end: 10, text: "@same" },
|
||||
{ start: 11, end: 15, text: "@two" },
|
||||
{ start: 16, end: 21, text: "@same" },
|
||||
{ start: 22, end: 27, text: "@gone" },
|
||||
]
|
||||
const before = structuredClone(mentions)
|
||||
expect(realignPromptMentions("@two @same @one @same", mentions)).toEqual([
|
||||
{ start: 11, end: 15, text: "@one" },
|
||||
{ start: 5, end: 10, text: "@same" },
|
||||
{ start: 0, end: 4, text: "@two" },
|
||||
{ start: 16, end: 21, text: "@same" },
|
||||
undefined,
|
||||
])
|
||||
expect(mentions).toEqual(before)
|
||||
expect(
|
||||
realignPromptMentions("@foobar @foo", [
|
||||
{ start: 0, end: 4, text: "@foo" },
|
||||
{ start: 5, end: 12, text: "@foobar" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ start: 8, end: 12, text: "@foo" },
|
||||
{ start: 0, end: 7, text: "@foobar" },
|
||||
])
|
||||
expect(
|
||||
realignPromptMentions("@foobar @foobar", [
|
||||
{ start: 0, end: 4, text: "@foo" },
|
||||
{ start: 13, end: 20, text: "@foobar" },
|
||||
]),
|
||||
).toEqual([undefined, { start: 8, end: 15, text: "@foobar" }])
|
||||
expect(
|
||||
realignPromptMentions("@same @same", [
|
||||
{ start: 100, end: 105, text: "@same" },
|
||||
{ start: 4, end: 9, text: "@same" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ start: 6, end: 11, text: "@same" },
|
||||
{ start: 0, end: 5, text: "@same" },
|
||||
])
|
||||
})
|
||||
|
||||
test("realigns mixed prompt attachments without mutation", () => {
|
||||
const input = {
|
||||
text: "@file @gone @agent",
|
||||
files: [
|
||||
{ uri: "file:///file", mention: { start: 0, end: 5, text: "@file" } },
|
||||
{ uri: "data:image/png;base64,YWJj", name: "image.png" },
|
||||
{ uri: "file:///gone", mention: { start: 6, end: 11, text: "@gone" } },
|
||||
],
|
||||
agents: [{ name: "agent", mention: { start: 12, end: 18, text: "@agent" } }],
|
||||
} satisfies PromptInput.Prompt
|
||||
const before = structuredClone(input)
|
||||
const output = realignPromptInputMentions("@agent then @file", input)
|
||||
expect(output).toEqual({
|
||||
text: "@agent then @file",
|
||||
files: [
|
||||
{ uri: "file:///file", mention: { start: 12, end: 17, text: "@file" } },
|
||||
{ uri: "data:image/png;base64,YWJj", name: "image.png", mention: undefined },
|
||||
],
|
||||
agents: [{ name: "agent", mention: { start: 0, end: 6, text: "@agent" } }],
|
||||
})
|
||||
expect(input).toEqual(before)
|
||||
expect(output.files).not.toBe(input.files)
|
||||
expect(output.agents).not.toBe(input.agents)
|
||||
})
|
||||
|
||||
test("shifts mention hints when pasted placeholders expand", () => {
|
||||
const input = {
|
||||
text: "[Pasted text #1] @same @same",
|
||||
files: [{ uri: "file:///same", mention: { start: 23, end: 28, text: "@same" } }],
|
||||
} satisfies PromptInput.Prompt
|
||||
const expanded = expandPromptInputPastedText(input, [
|
||||
{ text: "a much longer pasted value", source: { start: 0, end: 16 } },
|
||||
])
|
||||
expect(expanded.files?.[0]?.mention).toEqual({ start: 33, end: 38, text: "@same" })
|
||||
expect(realignPromptInputMentions(expanded.text, expanded).files?.[0]?.mention).toEqual({
|
||||
start: 33,
|
||||
end: 38,
|
||||
text: "@same",
|
||||
})
|
||||
})
|
||||
24
packages/tui/test/prompt/parse.test.ts
Normal file
24
packages/tui/test/prompt/parse.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { parseFileLineRange, parseSlashHead } from "../../src/prompt/parse"
|
||||
|
||||
test("preserves file line-range parsing semantics", () => {
|
||||
expect([
|
||||
parseFileLineRange("src/app.ts#12-20"),
|
||||
parseFileLineRange("src/app.ts#12-"),
|
||||
parseFileLineRange("src/app.ts#12-12"),
|
||||
parseFileLineRange("src/app.ts#bad"),
|
||||
parseFileLineRange("src/app.ts"),
|
||||
]).toEqual([
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: 20 } },
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
|
||||
{ base: "src/app.ts" },
|
||||
{ base: "src/app.ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps frontend-specific slash separators", () => {
|
||||
expect(parseSlashHead("/editor\rfirst")).toEqual({ name: "editor\rfirst", arguments: "", end: 13 })
|
||||
expect(parseSlashHead("/editor\rfirst", /\s/)).toEqual({ name: "editor", arguments: "first", end: 7 })
|
||||
expect(parseSlashHead("editor")).toBeUndefined()
|
||||
})
|
||||
|
|
@ -45,6 +45,17 @@ test("resolveTheme rejects circular color refs", () => {
|
|||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
test("resolveTheme preserves full theme numeric color and marker semantics", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.theme.primary = 6
|
||||
delete item.theme.selectedListItemText
|
||||
|
||||
const theme = resolveTheme(item, "dark")
|
||||
expect(theme.primary.intent).toBe("rgb")
|
||||
expect(theme.selectedListItemText).toBe(theme.background)
|
||||
expect(theme._hasSelectedListItemText).toBe(false)
|
||||
})
|
||||
|
||||
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
|
||||
return {
|
||||
palette,
|
||||
|
|
|
|||
35
packages/tui/test/ui/select-controller.test.ts
Normal file
35
packages/tui/test/ui/select-controller.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
moveSelection,
|
||||
moveSelectionOffset,
|
||||
reconcileSelection,
|
||||
revealSelectionOffset,
|
||||
} from "../../src/ui/select-controller"
|
||||
|
||||
test("reconciles and moves selections with explicit boundary policy", () => {
|
||||
expect([reconcileSelection(3, 0), reconcileSelection(4, 3), reconcileSelection(2, 6)]).toEqual([0, 2, 2])
|
||||
expect([
|
||||
moveSelection(0, { count: 3, delta: -1, policy: "clamp" }),
|
||||
moveSelection(2, { count: 3, delta: 1, policy: "clamp" }),
|
||||
moveSelection(0, { count: 3, delta: -1, policy: "wrap" }),
|
||||
moveSelection(2, { count: 3, delta: 1, policy: "wrap" }),
|
||||
]).toEqual([0, 2, 2, 0])
|
||||
})
|
||||
|
||||
test("reveals selections within bounded windows", () => {
|
||||
expect([
|
||||
revealSelectionOffset(5, { count: 20, limit: 8, selected: 3 }),
|
||||
revealSelectionOffset(3, { count: 20, limit: 8, selected: 11 }),
|
||||
revealSelectionOffset(3, { count: 20, limit: 8, selected: 10 }),
|
||||
revealSelectionOffset(20, { count: 20, limit: 8, selected: 19 }),
|
||||
]).toEqual([3, 4, 3, 12])
|
||||
})
|
||||
|
||||
test("keeps movement offsets and preview margins in bounds", () => {
|
||||
expect([
|
||||
moveSelectionOffset(0, { count: 20, limit: 8, selected: 6, direction: 1 }),
|
||||
moveSelectionOffset(8, { count: 20, limit: 8, selected: 9, direction: -1 }),
|
||||
moveSelectionOffset(12, { count: 20, limit: 8, selected: 19, direction: 1 }),
|
||||
moveSelectionOffset(4, { count: 4, limit: 8, selected: 3, direction: 1 }),
|
||||
]).toEqual([1, 7, 12, 0])
|
||||
})
|
||||
103
packages/tui/test/util/form.test.ts
Normal file
103
packages/tui/test/util/form.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
isFormAnswerField,
|
||||
} from "../../src/util/form"
|
||||
import type { FormAnswerField } from "../../src/util/form"
|
||||
|
||||
const option = { key: "choice", type: "string", options: [{ value: "one", label: "One" }], custom: true } satisfies FormField
|
||||
const selection = {
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "one", label: "One" },
|
||||
{ value: "two", label: "Two" },
|
||||
],
|
||||
custom: true,
|
||||
} satisfies FormAnswerField
|
||||
|
||||
test("initializes configured and custom defaults", () => {
|
||||
expect(
|
||||
formInitialValues([
|
||||
{ key: "mode", type: "string", options: [{ value: "fast", label: "Fast" }], default: "fast" },
|
||||
{ ...option, key: "note", default: "detailed" },
|
||||
{ ...option, key: "configured", default: "one" },
|
||||
{ key: "count", type: "number", default: 0 },
|
||||
{ key: "authorize", type: "external", url: "https://example.com" },
|
||||
]),
|
||||
).toEqual({
|
||||
answers: { mode: "fast", note: "detailed", configured: "one", count: 0 },
|
||||
custom: { note: "detailed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("validates every supported field constraint", () => {
|
||||
const validate = (field: FormAnswerField, value: FormValue | undefined, error: string | undefined) =>
|
||||
expect(formValidateValue(field, value)).toBe(error)
|
||||
const string = (extra: Partial<Extract<FormAnswerField, { type: "string" }>> = {}) =>
|
||||
({ key: "value", type: "string", ...extra }) satisfies FormAnswerField
|
||||
const multi = (extra: Partial<Extract<FormAnswerField, { type: "multiselect" }>> = {}) =>
|
||||
({ key: "value", type: "multiselect", options: [], ...extra }) satisfies FormAnswerField
|
||||
|
||||
validate(string({ required: true }), undefined, "Answer required")
|
||||
validate(multi({ required: true }), [], "Select at least one option")
|
||||
validate(string(), true, "Expected text")
|
||||
validate(string({ minLength: 3 }), "ab", "Must be at least 3 characters")
|
||||
validate(string({ maxLength: 2 }), "abc", "Must be at most 2 characters")
|
||||
validate(string({ pattern: "^a+$" }), "bbb", "Must match pattern: ^a+$")
|
||||
validate(string({ pattern: "[" }), "value", "Invalid pattern: [")
|
||||
validate(string({ format: "email" }), "invalid", "Expected an email address")
|
||||
validate(string({ format: "uri" }), "not a URL", "Expected a URL")
|
||||
validate(string({ format: "date" }), "2025-02-29", "Expected a date (YYYY-MM-DD)")
|
||||
validate(string({ format: "date-time" }), "not a date", "Expected a date and time")
|
||||
validate(string({ options: [{ value: "yes", label: "Yes" }] }), "no", "Select an available option")
|
||||
validate({ key: "value", type: "number" }, Number.NaN, "Expected a number")
|
||||
validate({ key: "value", type: "integer" }, 1.5, "Expected an integer")
|
||||
validate({ key: "value", type: "number", minimum: 2 }, 1, "Must be at least 2")
|
||||
validate({ key: "value", type: "number", maximum: 2 }, 3, "Must be at most 2")
|
||||
validate({ key: "value", type: "boolean" }, "yes", "Expected yes or no")
|
||||
validate(multi(), "yes", "Expected selections")
|
||||
validate(multi({ minItems: 2 }), ["one"], "Select at least 2")
|
||||
validate(multi({ maxItems: 1 }), ["one", "two"], "Select at most 1")
|
||||
validate(multi({ options: [{ value: "one", label: "One" }] }), ["two"], "Select only available options")
|
||||
validate(multi({ custom: true }), ["custom"], undefined)
|
||||
})
|
||||
|
||||
test("shares field classification, rows, selection, and display", () => {
|
||||
const text = { key: "name", type: "string", title: "Name" } satisfies FormField
|
||||
const external = { key: "authorize", type: "external", url: "https://example.com" } satisfies FormField
|
||||
expect([isFormAnswerField(text), isFormAnswerField(external)]).toEqual([true, false])
|
||||
expect([formLabel(text), formLabel(external)]).toEqual(["Name", "https://example.com"])
|
||||
expect([formTextual(text), formTextual(option), formCustom(option)]).toEqual([true, false, true])
|
||||
expect(formRows({ key: "value", type: "boolean" })).toEqual([
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
])
|
||||
expect(formRows({ ...option, options: [{ value: "one", label: "One", description: "First" }] })).toEqual([
|
||||
{ value: "one", label: "One", description: "First" },
|
||||
])
|
||||
expect(formRows({ key: "value", type: "number" })).toEqual([])
|
||||
expect([formSelected(selection, "two"), formSelected(selection, "custom"), formSelected(selection, undefined)]).toEqual([
|
||||
1, 2, 0,
|
||||
])
|
||||
expect(formDisplayValue(selection, ["one", "custom"], "(none)")).toBe("One, custom")
|
||||
expect([formDisplayValue(selection, [], ""), formDisplayValue(selection, [], "(none)")]).toEqual(["", "(none)"])
|
||||
})
|
||||
|
||||
test("updates multiselects without mutating their source", () => {
|
||||
const source = ["one", "custom"]
|
||||
expect(formToggleMultiselect(source, "one")).toEqual(["custom"])
|
||||
expect(formToggleMultiselect(source, "two")).toEqual(["one", "custom", "two"])
|
||||
expect(formSetMultiselectCustom(source, "custom", "replacement")).toEqual(["one", "replacement"])
|
||||
expect(source).toEqual(["one", "custom"])
|
||||
})
|
||||
17
packages/tui/test/util/path-format.test.ts
Normal file
17
packages/tui/test/util/path-format.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { formatPath } from "../../src/util/path-format"
|
||||
|
||||
test("formats relative, home, and foreign paths", () => {
|
||||
expect(formatPath(".", { base: "/work/project" })).toBe(".")
|
||||
expect(formatPath("../shared/a.ts", { base: "/work/project" })).toBe("/work/shared/a.ts")
|
||||
expect(formatPath("/home/test/project", { base: "/work", home: "/home/test" })).toBe("~/project")
|
||||
expect(formatPath("src\\a.ts", { base: "/work", forwardSlashes: true })).toBe("src/a.ts")
|
||||
expect(formatPath("C:/", { base: "/work" })).toBe("C:/")
|
||||
expect(formatPath("C:\\Users\\tester", { base: "/work", forwardSlashes: true })).toBe("C:/Users/tester")
|
||||
expect(formatPath("..\\shared\\a.ts", { base: "C:\\work\\project", forwardSlashes: true })).toBe(
|
||||
"C:/work/shared/a.ts",
|
||||
)
|
||||
expect(
|
||||
formatPath("C:\\Users\\test\\project", { base: "C:\\work", home: "C:\\Users\\test" }),
|
||||
).toBe("~/project")
|
||||
})
|
||||
19
packages/tui/test/util/permission.test.ts
Normal file
19
packages/tui/test/util/permission.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { permissionPresentation } from "../../src/util/permission"
|
||||
|
||||
test("preserves permission roots and self-contained metadata", () => {
|
||||
expect(permissionPresentation({ action: "external_directory", resources: ["/*"] }).title).toBe(
|
||||
"Access external directory /",
|
||||
)
|
||||
expect(permissionPresentation({ action: "external_directory", resources: ["C:/*"] }).title).toBe(
|
||||
"Access external directory C:/",
|
||||
)
|
||||
expect(permissionPresentation({ action: "webfetch", resources: [], metadata: { url: "https://example.com" } })).toMatchObject({
|
||||
title: "WebFetch https://example.com",
|
||||
lines: ["URL: https://example.com"],
|
||||
})
|
||||
expect(permissionPresentation({ action: "websearch", resources: [], metadata: { query: "releases" } })).toMatchObject({
|
||||
title: 'Web Search "releases"',
|
||||
lines: ["Query: releases"],
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../src/util/tool-display"
|
||||
|
||||
test("normalizes shared tool primitives", () => {
|
||||
expect(["bash", "task", "apply_patch", "plugin_tool"].map(canonicalToolName)).toEqual([
|
||||
"shell",
|
||||
"subagent",
|
||||
"patch",
|
||||
"plugin_tool",
|
||||
])
|
||||
expect([finiteNumber(-1.5), finiteNumber(Number.NaN), finiteNumber("1")]).toEqual([-1.5, undefined, undefined])
|
||||
expect(primitiveInputSummary({ command: "pwd", count: 2, nested: {} })).toBe("[command=pwd, count=2]")
|
||||
expect(primitiveInputSummary({ path: "src/a.ts", line: 2 }, ["path"])).toBe("[line=2]")
|
||||
})
|
||||
|
||||
describe("webSearchProviderLabel", () => {
|
||||
test("labels known providers", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue