refactor(tui): extract shared frontend helpers (#37868)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user