refactor(tui): extract shared frontend helpers (#37868)

This commit is contained in:
Simon Klee
2026-07-20 10:43:02 +02:00
committed by GitHub
parent deee40c572
commit 0eb71d0fc7
52 changed files with 1654 additions and 1613 deletions
@@ -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" },
},
])
})
})
+13
View File
@@ -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" }))
+6 -1
View File
@@ -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" })
})
+61
View 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
View 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
View 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()
})
+11
View File
@@ -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])
})
+103
View 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"])
})
@@ -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
View 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"],
})
})
+19 -1
View File
@@ -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", () => {