mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 14:06:21 +00:00
Compare commits
8
Commits
beta
...
oc-mini-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d3eb7237 | ||
|
|
829de0d1ff | ||
|
|
807ad7d6af | ||
|
|
7681326abb | ||
|
|
c28d4e1fa5 | ||
|
|
77fb7106b9 | ||
|
|
fc8fae90b3 | ||
|
|
b8793a1783 |
+1
-1
@@ -8,7 +8,7 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { ReadStream } from "node:tty"
|
||||
import { OPENCODE_VERSION } from "./version"
|
||||
|
||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||
|
||||
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
|
||||
argv: process.argv.slice(2),
|
||||
}
|
||||
return {
|
||||
version: OPENCODE_VERSION,
|
||||
terminal: { stdin: input.terminal.stdin },
|
||||
platform: process.platform,
|
||||
stdout: {
|
||||
|
||||
@@ -486,7 +486,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||
draft.mini = {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -494,7 +501,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||
mini: {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
},
|
||||
})
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type InteractiveStdin,
|
||||
usingInteractiveStdin,
|
||||
} from "../src/mini-host"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const model = { providerID: "openai", modelID: "gpt-5" }
|
||||
@@ -145,6 +146,7 @@ describe("Mini CLI host", () => {
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
|
||||
|
||||
expect(input.paths).toEqual({ home: directory.path })
|
||||
expect(input.version).toBe(OPENCODE_VERSION)
|
||||
expect(input.platform).toBe(process.platform)
|
||||
expect(typeof input.files.readText).toBe("function")
|
||||
const file = path.join(directory.path, "attachment.txt")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { oneCellFrame, type OneCellMotion } from "../ui/one-cell-motion"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function OneCellSpinner(props: {
|
||||
animation: OneCellMotion
|
||||
color: ColorInput
|
||||
animations?: boolean
|
||||
speed?: number
|
||||
paused?: boolean
|
||||
glow?: boolean
|
||||
age?: number
|
||||
still?: string
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const [elapsed, setElapsed] = createSignal(0)
|
||||
const sequenced = () => !!props.animation.intro || !!props.animation.once || !!props.animation.pace
|
||||
const frame = createMemo(() => oneCellFrame(props.animation, elapsed()))
|
||||
const complete = createMemo(() => frame().complete)
|
||||
const base = createMemo(() => parseColor(props.color))
|
||||
const colors = createMemo(() =>
|
||||
props.animation.levels?.map((level) => {
|
||||
const color = RGBA.clone(base())
|
||||
color.a *= level
|
||||
return color
|
||||
}),
|
||||
)
|
||||
const color = createMemo(() => {
|
||||
if (props.glow === false) return base()
|
||||
if (sequenced()) {
|
||||
const color = RGBA.clone(base())
|
||||
color.a *= frame().level
|
||||
return color
|
||||
}
|
||||
const palette = colors()
|
||||
return palette ? (frame: number) => palette[frame]! : base()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.animation
|
||||
props.animations
|
||||
setElapsed(props.age ?? 0)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!sequenced() || props.animations === false || props.paused || complete()) return
|
||||
let previous = performance.now()
|
||||
// Leave idle gaps: mini awaits renderer.idle() to flush and admit prompts.
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
const now = performance.now()
|
||||
setElapsed((value) => value + (now - previous) * (props.speed ?? 1))
|
||||
previous = now
|
||||
},
|
||||
Math.max(
|
||||
1000 / 60,
|
||||
Math.min(40, props.animation.interval / (props.speed ?? 1) / (props.animation.pace?.initial ?? 1)),
|
||||
),
|
||||
)
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
renderer.requestRender()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={1} height={1} flexShrink={0}>
|
||||
<Show when={props.animations !== false} fallback={<text fg={base()}>{props.still ?? "\u25aa"}</text>}>
|
||||
<spinner
|
||||
frames={sequenced() ? [frame().glyph] : props.animation.frames}
|
||||
interval={props.animation.interval / (props.speed ?? 1)}
|
||||
autoplay={!props.paused && !sequenced()}
|
||||
color={color()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -51,13 +51,7 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import {
|
||||
normalizePastedFilepath,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachment,
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { resolvePastedAttachments } from "./local-attachment"
|
||||
import { locationKey, useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
@@ -1454,36 +1448,19 @@ export function Prompt(props: PromptProps) {
|
||||
async function pasteInputText(text: string, changed: () => boolean) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
if (attachment) {
|
||||
if (changed()) return
|
||||
pasteLocalAttachment(filepath, attachment)
|
||||
return
|
||||
}
|
||||
|
||||
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||
if (filepaths.length > 1) {
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) break
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
if (attachments.length === filepaths.length) {
|
||||
if (changed()) return
|
||||
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||
const attachments = await resolvePastedAttachments(pastedContent, terminalEnvironment.platform)
|
||||
if (changed()) return
|
||||
if (attachments) {
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${attachment.filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
pasteAttachment(attachment)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (changed()) return
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
@@ -1508,18 +1485,6 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
}
|
||||
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
|
||||
@@ -26,6 +26,38 @@ export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMEN
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolvePastedAttachments(text: string, platform: string) {
|
||||
const pastedContent = text.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, platform)
|
||||
if (/^(https?):\/\//.test(filepath)) return undefined
|
||||
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
const attachments = attachment ? [{ filepath, attachment }] : []
|
||||
if (!attachment) {
|
||||
const filepaths = parsePastedFilepaths(pastedContent, platform)
|
||||
if (filepaths.length <= 1) return undefined
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) return undefined
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
}
|
||||
|
||||
return attachments.map((item) => {
|
||||
const filename = path.basename(item.filepath)
|
||||
if (item.attachment.type === "text") {
|
||||
return { type: "text" as const, content: item.attachment.content, filename }
|
||||
}
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: `data:${item.attachment.mime};base64,${Buffer.from(item.attachment.content).toString("base64")}`,
|
||||
filename,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".avif": "image/avif",
|
||||
".gif": "image/gif",
|
||||
|
||||
@@ -26,6 +26,24 @@ export const AttentionSoundName = Schema.Literals([
|
||||
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
|
||||
export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
|
||||
|
||||
export const MiniWorkSpinner = Schema.Literals([
|
||||
"block-soft-slide",
|
||||
"block-soft-sweep",
|
||||
"block-low-comet",
|
||||
"block-low-duet",
|
||||
"block-shuttle",
|
||||
"block-bridge",
|
||||
"block-squeeze",
|
||||
"small-toggle",
|
||||
"square-toggle",
|
||||
"grow-shrink",
|
||||
"quadrant-orbit",
|
||||
"crosshatch",
|
||||
"density-wave",
|
||||
"seed",
|
||||
])
|
||||
export type MiniWorkSpinner = Schema.Schema.Type<typeof MiniWorkSpinner>
|
||||
|
||||
export const Plugin = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
@@ -185,6 +203,9 @@ export const Info = Schema.Struct({
|
||||
splash: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide the entry and exit splash banners",
|
||||
}),
|
||||
work_spinner: Schema.optional(MiniWorkSpinner).annotate({
|
||||
description: "Work spinner animation in the Mini footer (default: block-soft-slide)",
|
||||
}),
|
||||
mono: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use monochrome ASCII output",
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StoryFooter } from "./footer"
|
||||
import { mermanLayoutsStory } from "./merman-layouts"
|
||||
import { sessionTabsStory } from "./session-tabs"
|
||||
import { sessionLocationMissingStory } from "./session-location-missing"
|
||||
import { oneCellSpinnerStory } from "./one-cell-spinner"
|
||||
|
||||
/**
|
||||
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
||||
@@ -16,7 +17,7 @@ export type Story = {
|
||||
render: (context: Plugin.Context) => JSX.Element
|
||||
}
|
||||
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
|
||||
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, oneCellSpinnerStory]
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { SEED_LAUNCH, SEED_WORK, WORK_SPINNERS, type OneCellMotion } from "../../../ui/one-cell-motion"
|
||||
import { SUBCELL_SPINNERS } from "./subcell-spinner.fixtures"
|
||||
|
||||
export type SpinnerFixture = OneCellMotion & {
|
||||
name: string
|
||||
description: string
|
||||
launch?: OneCellMotion
|
||||
}
|
||||
|
||||
function heldFrames(glyphs: string, holds: number[] = []) {
|
||||
return Array.from(glyphs).flatMap((glyph, index) => Array.from({ length: holds[index] ?? 1 }, () => glyph))
|
||||
}
|
||||
|
||||
function sequence(
|
||||
name: string,
|
||||
description: string,
|
||||
glyphs: string,
|
||||
interval: number,
|
||||
holds?: number[],
|
||||
): SpinnerFixture {
|
||||
return { name, description, frames: heldFrames(glyphs, holds), interval }
|
||||
}
|
||||
|
||||
function pulse(
|
||||
name: string,
|
||||
description: string,
|
||||
glyphs: string,
|
||||
duration: number,
|
||||
level: (phase: number) => number,
|
||||
holds?: number[],
|
||||
): SpinnerFixture {
|
||||
const shapes = heldFrames(glyphs, holds)
|
||||
const phases = Array.from({ length: duration / 40 }, (_, index) => index / (duration / 40))
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
frames: phases.map((phase) => shapes[Math.floor(phase * shapes.length)]!),
|
||||
interval: 40,
|
||||
// Keep a visible floor: a working indicator must never blink out entirely.
|
||||
levels: phases.map((phase) => 0.3 + 0.7 * level(phase)),
|
||||
}
|
||||
}
|
||||
|
||||
const breathe = (phase: number) => (1 - Math.cos(phase * 2 * Math.PI)) / 2
|
||||
const ember = (phase: number) => (phase < 0.1 ? phase / 0.1 : ((1 - phase) / 0.9) ** 3)
|
||||
const seedBreathe = pulse("Seed breathe", "A still seed carries a slow breath of light.", "\u25aa", 1600, breathe)
|
||||
const seedToggle = pulse(
|
||||
"Seed toggle",
|
||||
"An outline fills with light, then opens again.",
|
||||
"\u25ab\u25aa\u25aa\u25ab",
|
||||
1600,
|
||||
breathe,
|
||||
)
|
||||
export const ONE_CELL_SPINNERS: SpinnerFixture[] = [
|
||||
{
|
||||
...WORK_SPINNERS["small-toggle"],
|
||||
name: "Small toggle",
|
||||
description: "A small square opens and closes in an even rhythm.",
|
||||
},
|
||||
{
|
||||
...WORK_SPINNERS["square-toggle"],
|
||||
name: "Square toggle",
|
||||
description: "A larger square opens and closes at a slower pace.",
|
||||
},
|
||||
{
|
||||
...WORK_SPINNERS["grow-shrink"],
|
||||
name: "Grow / shrink",
|
||||
description: "An outline fills, grows, and returns to a seed.",
|
||||
},
|
||||
sequence(
|
||||
"Inset bloom",
|
||||
"A square within a square opens into a full bloom.",
|
||||
"\u25ab\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
120,
|
||||
[3],
|
||||
),
|
||||
sequence("Hollow bloom", "The outline grows before its center fills.", "\u25ab\u25a1\u25a3\u25a0\u25a3\u25a1", 160, [
|
||||
3,
|
||||
]),
|
||||
sequence(
|
||||
"Corner orbit",
|
||||
"A small square traces four corners without leaving its cell.",
|
||||
"\u25f0\u25f3\u25f2\u25f1",
|
||||
160,
|
||||
),
|
||||
{
|
||||
...WORK_SPINNERS["quadrant-orbit"],
|
||||
name: "Quadrant orbit",
|
||||
description: "Four corners take their turn, clockwise.",
|
||||
},
|
||||
sequence("Half rotation", "Light and shade circle a still square.", "\u25e7\u2b12\u25e8\u2b13", 200),
|
||||
{ ...WORK_SPINNERS.crosshatch, name: "Crosshatch", description: "Diagonal threads cross, then change direction." },
|
||||
{
|
||||
...WORK_SPINNERS["density-wave"],
|
||||
name: "Density wave",
|
||||
description: "Grain gathers into a block, then thins. The color stays still.",
|
||||
},
|
||||
sequence(
|
||||
"Fill / drain",
|
||||
"A narrow tide rises, then returns.",
|
||||
"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\u2587\u2586\u2585\u2584\u2583\u2582",
|
||||
80,
|
||||
[2, 2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2],
|
||||
),
|
||||
sequence(
|
||||
"Double beat",
|
||||
"Two quick swells, then a quiet seed.",
|
||||
"\u25aa\u25a0\u25aa\u25a0\u25aa",
|
||||
100,
|
||||
[1, 1, 2, 1, 11],
|
||||
),
|
||||
sequence("Beacon", "One large flash settles into a seed that stays in sight.", "\u25a0\u25a3\u25aa", 120, [1, 1, 12]),
|
||||
sequence(
|
||||
"Held bloom",
|
||||
"A seed swells, holds its bloom, then rests.",
|
||||
"\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
160,
|
||||
[4, 1, 2, 1, 2],
|
||||
),
|
||||
seedBreathe,
|
||||
pulse("Square breathe", "A larger square takes a longer breath.", "\u25a0", 2400, breathe),
|
||||
pulse("Inset breathe", "A square within a square holds a soft breath of light.", "\u25a3", 2000, breathe),
|
||||
pulse(
|
||||
"Bloom + glow",
|
||||
"A small bloom grows with the light, then recedes.",
|
||||
"\u25aa\u25a3\u25a0\u25a3\u25aa",
|
||||
1600,
|
||||
breathe,
|
||||
[6, 3, 2, 3, 6],
|
||||
),
|
||||
pulse("Soft heartbeat", "Two soft beats of light, then a quiet glow.", "\u25aa", 1600, (phase) =>
|
||||
Math.max(Math.exp(-(((phase - 0.2) / 0.08) ** 2)), 0.75 * Math.exp(-(((phase - 0.4) / 0.08) ** 2))),
|
||||
),
|
||||
pulse("Ember", "A quick spark leaves a long glow in a still square.", "\u25a3", 1600, ember),
|
||||
{ ...seedToggle, launch: SEED_LAUNCH },
|
||||
pulse("Seed ember", "A small, still seed catches light and lets it linger.", "\u25aa", 1600, ember),
|
||||
{
|
||||
...SEED_WORK,
|
||||
name: "Seed handoff",
|
||||
description: "A spark lingers, then the breath grows quiet.",
|
||||
launch: SEED_LAUNCH,
|
||||
},
|
||||
...SUBCELL_SPINNERS,
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { OneCellSpinner } from "../../../component/one-cell-spinner"
|
||||
import { useConfig } from "../../../config"
|
||||
import { entrySplashLayout } from "../../../mini/splash"
|
||||
import { stringWidth } from "../../../util/string-width"
|
||||
import { StoryFooter } from "./footer"
|
||||
import type { Story } from "./index"
|
||||
import { ONE_CELL_SPINNERS } from "./one-cell-spinner.fixtures"
|
||||
|
||||
function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const config = useConfig()
|
||||
const [selected, setSelected] = createSignal(39)
|
||||
const [speed, setSpeed] = createSignal(1)
|
||||
const [animations, setAnimations] = createSignal(config.data.animations ?? true)
|
||||
const [paused, setPaused] = createSignal(false)
|
||||
const [glow, setGlow] = createSignal(true)
|
||||
const [solo, setSolo] = createSignal(false)
|
||||
const [epoch, setEpoch] = createSignal(0)
|
||||
const [age, setAge] = createSignal(0)
|
||||
const animation = () => ONE_CELL_SPINNERS[selected()]!
|
||||
const timing = createMemo(() => {
|
||||
const item = animation()
|
||||
const cycle = (item.frames.length * item.interval) / speed()
|
||||
if (!item.pace) return `${Math.round((item.interval / speed()) * 10) / 10}ms tick / ${cycle}ms cycle`
|
||||
return `${(cycle / item.pace.initial / 1000).toFixed(2)}s to ${(cycle / item.pace.final / 1000).toFixed(2)}s cycle`
|
||||
})
|
||||
const previewWidth = () => (solo() ? Math.min(dimensions().width, 64) : dimensions().width)
|
||||
const speeds = createMemo(() => (dimensions().width >= 60 ? [0.5, 1, 2] : [speed()]))
|
||||
const splash = createMemo(() =>
|
||||
entrySplashLayout({ width: Math.max(1, previewWidth() - 8), version: "1.18.4", detail: "~/src/opencode" }),
|
||||
)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const revealSelected = () => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const rows = scroll.viewport.height
|
||||
scroll.scrollTo(Math.max(0, Math.min(selected() - Math.floor(rows / 2), ONE_CELL_SPINNERS.length - rows)))
|
||||
}
|
||||
createEffect(revealSelected)
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: solo() ? "Leave focus" : "Back to storybook",
|
||||
group: "Storybook",
|
||||
run: () => {
|
||||
if (solo()) return setSolo(false)
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "up,k",
|
||||
title: "Previous animation",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((value) => (value + ONE_CELL_SPINNERS.length - 1) % ONE_CELL_SPINNERS.length),
|
||||
},
|
||||
{
|
||||
bind: "down,j",
|
||||
title: "Next animation",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((value) => (value + 1) % ONE_CELL_SPINNERS.length),
|
||||
},
|
||||
{
|
||||
bind: "s",
|
||||
title: "Cycle preview speed",
|
||||
group: "Storybook",
|
||||
run: () => setSpeed((value) => (value === 0.5 ? 1 : value === 1 ? 2 : 0.5)),
|
||||
},
|
||||
{ bind: "space", title: "Pause / resume", group: "Storybook", run: () => setPaused((value) => !value) },
|
||||
{ bind: "a", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
|
||||
{ bind: "g", title: "Toggle intensity pulse", group: "Storybook", run: () => setGlow((value) => !value) },
|
||||
{
|
||||
bind: "f",
|
||||
title: "Focus selected animation",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setSolo((value) => !value)
|
||||
if (solo()) setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
bind: "p",
|
||||
title: "Replay animation",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setAge(0)
|
||||
setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
...(animation().pace
|
||||
? [
|
||||
{
|
||||
bind: "t",
|
||||
title: "Cycle work age",
|
||||
group: "Storybook",
|
||||
run: () => setAge((value) => (value === 0 ? 30_000 : value === 30_000 ? 60_000 : 0)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset comparison",
|
||||
group: "Storybook",
|
||||
run: () =>
|
||||
batch(() => {
|
||||
setSelected(39)
|
||||
setSpeed(1)
|
||||
setAnimations(config.data.animations ?? true)
|
||||
setPaused(false)
|
||||
setGlow(true)
|
||||
setSolo(false)
|
||||
setAge(0)
|
||||
setEpoch((value) => value + 1)
|
||||
}),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background.default}
|
||||
justifyContent={solo() ? "center" : undefined}
|
||||
alignItems={solo() ? "center" : undefined}
|
||||
>
|
||||
<Show when={!solo()}>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
one-cell motion lab.
|
||||
</text>
|
||||
</Show>
|
||||
<For each={[epoch()]}>
|
||||
{() => (
|
||||
<>
|
||||
<Show when={!solo()}>
|
||||
<box flexDirection="row" height={1} flexShrink={0} paddingLeft={1}>
|
||||
<text width={22} fg={theme.text.subdued}>
|
||||
pattern
|
||||
</text>
|
||||
<For each={speeds()}>
|
||||
{(value) => (
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
{value}x
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<text fg={theme.text.subdued}>cycle @1x</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={scroll}
|
||||
flexGrow={1}
|
||||
minHeight={1}
|
||||
viewportOptions={{ paddingLeft: 1 }}
|
||||
onSizeChange={() => queueMicrotask(revealSelected)}
|
||||
>
|
||||
<For each={ONE_CELL_SPINNERS}>
|
||||
{(item, index) => (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
<text
|
||||
width={22}
|
||||
wrapMode="none"
|
||||
fg={index() === selected() ? theme.text.formfield.selected : theme.text.formfield.default}
|
||||
>
|
||||
{index() === selected() ? ">" : " "}
|
||||
{String(index() + 1).padStart(2)} {item.name.toLowerCase()}.
|
||||
</text>
|
||||
<For each={speeds()}>
|
||||
{(value) => (
|
||||
<box width={7} flexShrink={0}>
|
||||
<OneCellSpinner
|
||||
animation={item}
|
||||
age={age()}
|
||||
speed={value}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<text width={10} fg={theme.text.subdued}>
|
||||
{item.pace ? "adaptive" : `${item.frames.length * item.interval}ms`}
|
||||
</text>
|
||||
<Show when={dimensions().width >= 80}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{[...new Set(item.frames)].join(" ")}
|
||||
{item.levels ? " + intensity" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<box
|
||||
width={previewWidth()}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
alignItems={solo() ? "center" : undefined}
|
||||
>
|
||||
<text fg={theme.text.default} maxWidth="100%" attributes={solo() ? TextAttributes.BOLD : 0}>
|
||||
<Show when={!solo()}>{String(selected() + 1).padStart(2, "0")} / </Show>
|
||||
{animation().name.toLowerCase()}.
|
||||
</text>
|
||||
<Show when={!solo()}>
|
||||
<text fg={theme.text.subdued} maxWidth="100%">
|
||||
{animation().description}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>
|
||||
{speed()}x: {timing()}
|
||||
</text>
|
||||
</Show>
|
||||
<box
|
||||
width={
|
||||
solo() ? Math.min(previewWidth() - 2, stringWidth(splash().label + splash().metadata) + 7) : "100%"
|
||||
}
|
||||
marginTop={solo() ? 1 : 0}
|
||||
>
|
||||
<box height={1} flexDirection="row">
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
work
|
||||
</text>
|
||||
<OneCellSpinner
|
||||
animation={animation()}
|
||||
age={age()}
|
||||
speed={speed()}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
/>
|
||||
<text fg={theme.text.default}> esc stop</text>
|
||||
</box>
|
||||
<box height={1} flexDirection="row">
|
||||
<text width={7} fg={theme.text.subdued}>
|
||||
launch
|
||||
</text>
|
||||
<OneCellSpinner
|
||||
animation={animation().launch ?? animation()}
|
||||
speed={speed()}
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.default}
|
||||
/>
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
{splash().label.slice(1)}
|
||||
<span style={{ fg: theme.text.subdued }}>{splash().metadata}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!solo()}>
|
||||
<StoryFooter
|
||||
context={props.context}
|
||||
title="motion lab."
|
||||
details={[animations() ? (paused() ? "paused" : "playing") : "motion off", glow() ? "glow on" : "shape only"]}
|
||||
status={animation().pace ? `start at ${age() / 1000}s | slows after 30s` : undefined}
|
||||
controls={[
|
||||
{ shortcut: "j/k", label: "select" },
|
||||
{ shortcut: "s", label: "speed" },
|
||||
{ shortcut: "space", label: "pause" },
|
||||
{ shortcut: "a", label: "motion" },
|
||||
{ shortcut: "g", label: "glow" },
|
||||
{ shortcut: "f", label: "focus" },
|
||||
{ shortcut: "p", label: "replay" },
|
||||
...(animation().pace ? [{ shortcut: "t", label: "age 0/30/60s" }] : []),
|
||||
{ shortcut: "r", label: "reset" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export const oneCellSpinnerStory: Story = {
|
||||
id: "one-cell-spinners",
|
||||
title: "one-cell spinners.",
|
||||
render: (context) => <OneCellSpinnerStory context={context} />,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
BLOCK_LOW_COMET,
|
||||
BLOCK_SOFT_SLIDE,
|
||||
BLOCK_SOFT_SWEEP,
|
||||
SEED_LAUNCH,
|
||||
WORK_SPINNERS,
|
||||
} from "../../../ui/one-cell-motion"
|
||||
import { octantGlyph } from "../../../ui/subcell"
|
||||
import type { SpinnerFixture } from "./one-cell-spinner.fixtures"
|
||||
|
||||
const perimeter = [0, 1, 3, 5, 7, 6, 4, 2]
|
||||
const patterns = [
|
||||
{
|
||||
name: "comet",
|
||||
description: "Three pixels chase the edge.",
|
||||
interval: 100,
|
||||
masks: perimeter.map(
|
||||
(point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!) | (1 << perimeter[(index + 6) % 8]!),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "orbit",
|
||||
description: "Two pixels keep a steady orbit.",
|
||||
interval: 140,
|
||||
masks: perimeter.map((point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!)),
|
||||
},
|
||||
{
|
||||
name: "duet",
|
||||
description: "Two pairs circle opposite edges.",
|
||||
interval: 180,
|
||||
masks: perimeter
|
||||
.slice(0, 4)
|
||||
.map((_, index) => [0, 3, 4, 7].reduce((mask, offset) => mask | (1 << perimeter[(index + offset) % 8]!), 0)),
|
||||
},
|
||||
{
|
||||
name: "scan",
|
||||
description: "A row sweeps down, pauses, and returns.",
|
||||
interval: 130,
|
||||
masks: [0, 0, 1, 2, 3, 3, 2, 1].map((row) => 3 << (row * 2)),
|
||||
},
|
||||
{
|
||||
name: "weave",
|
||||
description: "Four pixels pass from side to side.",
|
||||
interval: 200,
|
||||
masks: [0x55, 0x69, 0xaa, 0x96],
|
||||
},
|
||||
{
|
||||
name: "tide",
|
||||
description: "Rows rise from the floor, then recede.",
|
||||
interval: 160,
|
||||
masks: [0xc0, 0xc0, 0xf0, 0xfc, 0xff, 0xfc, 0xf0],
|
||||
},
|
||||
{
|
||||
name: "low comet",
|
||||
description: "The tail recedes before the head moves. The top row stays empty.",
|
||||
motion: BLOCK_LOW_COMET,
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "low duet",
|
||||
description: "Two tails recede, then the heads move. The top row stays empty.",
|
||||
motion: WORK_SPINNERS["block-low-duet"],
|
||||
blockOnly: true,
|
||||
},
|
||||
// The middle four octants are bits 2-5: left column 0x14, right column 0x28.
|
||||
{
|
||||
name: "shuttle",
|
||||
description: "A narrow bar moves left and right through the middle four.",
|
||||
motion: WORK_SPINNERS["block-shuttle"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "bridge",
|
||||
description: "The bar stretches across the middle four, then settles opposite.",
|
||||
motion: WORK_SPINNERS["block-bridge"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "soft sweep",
|
||||
description: "A staggered wipe crosses the middle four and retraces its path.",
|
||||
motion: BLOCK_SOFT_SWEEP,
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "squeeze",
|
||||
description: "The middle bar folds, hops sideways, and opens.",
|
||||
motion: WORK_SPINNERS["block-squeeze"],
|
||||
blockOnly: true,
|
||||
},
|
||||
{
|
||||
name: "soft slide",
|
||||
description: "The middle bar softens before each sideways step.",
|
||||
blockOnly: true,
|
||||
motion: BLOCK_SOFT_SLIDE,
|
||||
},
|
||||
]
|
||||
|
||||
export const SUBCELL_SPINNERS: SpinnerFixture[] = patterns.flatMap((pattern) =>
|
||||
(pattern.blockOnly ? ["block"] : ["block", "dot"]).map((style) => ({
|
||||
name: `${style} ${pattern.name}`,
|
||||
description: `${pattern.description} ${style === "block" ? "2x4 octants need a recent font." : "2x4 braille dots."}`,
|
||||
...(pattern.motion ?? {
|
||||
interval: pattern.interval!,
|
||||
frames: pattern.masks!.map((mask) => {
|
||||
if (style === "dot") {
|
||||
// Braille numbers its dots by column rather than by raster row.
|
||||
const dots = [0, 3, 1, 4, 2, 5, 6, 7].reduce((value, bit, index) => value | (((mask >> index) & 1) << bit), 0)
|
||||
return String.fromCodePoint(0x2800 + dots)
|
||||
}
|
||||
return octantGlyph(mask)
|
||||
}),
|
||||
}),
|
||||
launch: SEED_LAUNCH,
|
||||
})),
|
||||
)
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "./types"
|
||||
|
||||
const KINDS = [
|
||||
"motion",
|
||||
"markdown",
|
||||
"table",
|
||||
"text",
|
||||
@@ -152,6 +153,7 @@ type State = {
|
||||
perms: Map<string, Perm>
|
||||
forms: Map<string, FormRequest>
|
||||
started: Set<string>
|
||||
motion?: AbortController
|
||||
}
|
||||
|
||||
type Input = {
|
||||
@@ -814,6 +816,17 @@ function emitForm(state: State, kind: FormKind = "question"): void {
|
||||
}
|
||||
|
||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||
if (kind === "motion") {
|
||||
note(state.footer, "Working indicator demo: 70 seconds, no model calls. Interrupt to stop early.")
|
||||
const controller = new AbortController()
|
||||
state.motion = controller
|
||||
try {
|
||||
await wait(70_000, signal ? AbortSignal.any([controller.signal, signal]) : controller.signal)
|
||||
} finally {
|
||||
state.motion = undefined
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (kind === "text") {
|
||||
await emitText(state, body || SAMPLE_MARKDOWN, signal)
|
||||
return true
|
||||
@@ -900,6 +913,7 @@ function intro(state: State): void {
|
||||
"- /form question",
|
||||
"- /form external",
|
||||
"- /fmt markdown",
|
||||
"- /fmt motion",
|
||||
"- /fmt table",
|
||||
"- /fmt text your custom text",
|
||||
].join("\n"),
|
||||
@@ -1037,6 +1051,11 @@ export function createRunDemo(input: Input) {
|
||||
return {
|
||||
start,
|
||||
prompt,
|
||||
interrupt() {
|
||||
if (!state.motion) return false
|
||||
state.motion.abort()
|
||||
return true
|
||||
},
|
||||
permission,
|
||||
formReply,
|
||||
formCancel,
|
||||
|
||||
@@ -194,6 +194,11 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
const raw = cleanRunText(commit.text)
|
||||
const mono = options?.mono === true
|
||||
|
||||
if (commit.image) {
|
||||
const caption = raw.trim() || "Image"
|
||||
return commit.kind === "user" ? userBody(caption, mono) : textBody(mono ? monoToolText(caption, true) : caption)
|
||||
}
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw, mono)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
|
||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { Config } from "../config"
|
||||
import { OneCellSpinner } from "../component/one-cell-spinner"
|
||||
import { SEED_MONO, WORK_SPINNERS } from "../ui/one-cell-motion"
|
||||
import {
|
||||
FOOTER_COMPACT_WIDTH,
|
||||
RunFooterMenu,
|
||||
createFooterMenuState,
|
||||
footerMenuText,
|
||||
type RunFooterMenuItem,
|
||||
} from "./footer.menu"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { monoShortcut } from "./mono"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
@@ -78,7 +88,12 @@ const PANEL_FRAME_ROWS = 6
|
||||
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const SUBAGENT_LIST_ROWS = 12
|
||||
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const PANEL_PAGE = PANEL_LIST_ROWS - 1
|
||||
export function footerPanelLayout(height: number, limit = PANEL_LIST_ROWS) {
|
||||
const available = Math.max(3, height - 1)
|
||||
const compact = available < limit + PANEL_FRAME_ROWS
|
||||
const frame = compact ? 2 : PANEL_FRAME_ROWS
|
||||
return { compact, frame, limit: Math.max(1, Math.min(limit, available - frame)) }
|
||||
}
|
||||
const HALF_BLOCK_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
@@ -138,10 +153,17 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
onKey?: (event: KeyEvent, item: T | undefined) => boolean
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const layout = createMemo(() => {
|
||||
term()
|
||||
// The panel mounts before the footer expands, so its initial render height is stale.
|
||||
return footerPanelLayout(renderer.terminalHeight, input.limit)
|
||||
})
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const items = createMemo<T[]>(() => match(query(), input.entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: () => layout().limit })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
@@ -161,7 +183,9 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
term()
|
||||
const desired = footerPanelLayout(renderer.terminalHeight, input.limit)
|
||||
input.onRows?.(Math.max(1, Math.min(items().length, desired.limit)) + desired.frame)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
@@ -201,13 +225,13 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
|
||||
if (name === "pageup") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() - PANEL_PAGE)
|
||||
menu.reveal(menu.selected() - Math.max(1, menu.limit() - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() + PANEL_PAGE)
|
||||
menu.reveal(menu.selected() + Math.max(1, menu.limit() - 1))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,6 +268,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
setQuery,
|
||||
items,
|
||||
menu,
|
||||
layout,
|
||||
inputRef(input: InputRenderable) {
|
||||
field = input
|
||||
},
|
||||
@@ -263,50 +288,66 @@ function PanelShell(props: {
|
||||
children: JSX.Element
|
||||
hint?: string
|
||||
mono?: boolean
|
||||
background?: boolean
|
||||
layout: ReturnType<typeof footerPanelLayout>
|
||||
}) {
|
||||
const background = () => props.theme().shade
|
||||
const term = useTerminalDimensions()
|
||||
const pad = () => (term().width < FOOTER_COMPACT_WIDTH ? 1 : panelPad(props.mono))
|
||||
const header = createMemo(() => {
|
||||
const width = Math.max(0, term().width - pad() * 2 - 4)
|
||||
const title = footerMenuText(props.title, width, props.mono)
|
||||
const count = countLabel(props.count, props.total, props.query)
|
||||
const showCount = props.countVisible !== false && stringWidth(props.title) + stringWidth(count) + 1 <= width
|
||||
const hint =
|
||||
props.hint &&
|
||||
stringWidth(props.title) + (showCount ? stringWidth(count) + 1 : 0) + stringWidth(props.hint) + 3 <= width
|
||||
return { title, count: showCount ? count : undefined, hint: hint ? props.hint : undefined }
|
||||
})
|
||||
const background = () => (props.background === false ? "transparent" : props.theme().shade)
|
||||
const content = (
|
||||
<>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
paddingLeft={pad()}
|
||||
paddingRight={pad()}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
gap={0}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
|
||||
{props.title}
|
||||
{header().title}
|
||||
</text>
|
||||
{props.countVisible !== false ? (
|
||||
{header().count ? (
|
||||
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
||||
{countLabel(props.count, props.total, props.query)}
|
||||
{" " + header().count}
|
||||
</text>
|
||||
) : null}
|
||||
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
||||
<box minWidth={1} flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
||||
{header().hint ? `${header().hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
||||
</text>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
paddingLeft={pad()}
|
||||
paddingRight={pad()}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<input
|
||||
width="100%"
|
||||
focusedBackgroundColor={background()}
|
||||
focusedTextColor={props.theme().text}
|
||||
focusedBackgroundColor={props.background === false ? "transparent" : props.theme().formfieldFocusedBg}
|
||||
focusedTextColor={
|
||||
props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText
|
||||
}
|
||||
placeholder={props.placeholder}
|
||||
placeholderColor={props.theme().muted}
|
||||
cursorColor={props.theme().highlight}
|
||||
cursorColor={props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText}
|
||||
onInput={props.onQuery}
|
||||
ref={(input) => {
|
||||
props.inputRef(input)
|
||||
@@ -319,7 +360,7 @@ function PanelShell(props: {
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
|
||||
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
|
||||
{props.children}
|
||||
</box>
|
||||
@@ -330,8 +371,14 @@ function PanelShell(props: {
|
||||
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{content}
|
||||
</box>
|
||||
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{props.mono ? null : (
|
||||
<box
|
||||
width="100%"
|
||||
height={props.layout.compact ? 0 : 1}
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.layout.compact || props.mono || props.background === false ? null : (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
@@ -562,6 +609,7 @@ export function RunCommandMenuBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Commands"
|
||||
layout={controller.layout()}
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
@@ -577,8 +625,9 @@ export function RunCommandMenuBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -609,6 +658,7 @@ export function RunAgentSelectBody(props: {
|
||||
display: agent.id,
|
||||
description: agent.description,
|
||||
footer: props.current() === agent.id ? "current" : undefined,
|
||||
footerTone: "selection" as const,
|
||||
keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`,
|
||||
id: agent.id,
|
||||
current: props.current() === agent.id,
|
||||
@@ -625,6 +675,7 @@ export function RunAgentSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select agent"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -639,8 +690,9 @@ export function RunAgentSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No agents found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -659,6 +711,7 @@ export function RunSettingsBody(props: {
|
||||
onClose: () => void
|
||||
onChange: (change: MiniSettingChange) => void | Promise<void>
|
||||
mono?: boolean
|
||||
animations?: boolean
|
||||
}) {
|
||||
const [saving, setSaving] = createSignal<keyof MiniSettings>()
|
||||
const entries = createMemo<SettingEntry[]>(() => [
|
||||
@@ -666,6 +719,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Thinking",
|
||||
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
|
||||
footerTone: saving() === "thinking" ? "running" : "selection",
|
||||
keywords: `thinking reasoning ${props.settings().thinking}`,
|
||||
key: "thinking",
|
||||
},
|
||||
@@ -673,6 +727,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Shell",
|
||||
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
|
||||
footerTone: saving() === "shell_output" ? "running" : "selection",
|
||||
keywords: `shell tool command output ${props.settings().shell_output}`,
|
||||
key: "shell_output",
|
||||
},
|
||||
@@ -680,6 +735,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Transcript",
|
||||
display: "Turn summary",
|
||||
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
|
||||
footerTone: saving() === "turn_summary" ? "running" : "selection",
|
||||
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
||||
key: "turn_summary",
|
||||
},
|
||||
@@ -687,6 +743,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Footer details",
|
||||
footer: saving() === "footer" ? "saving" : props.settings().footer,
|
||||
footerTone: saving() === "footer" ? "running" : "selection",
|
||||
keywords: `footer status activity model context usage ${props.settings().footer}`,
|
||||
key: "footer",
|
||||
},
|
||||
@@ -694,6 +751,7 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Splash",
|
||||
footer: saving() === "splash" ? "saving" : props.settings().splash,
|
||||
footerTone: saving() === "splash" ? "running" : "selection",
|
||||
keywords: `splash entry exit banner ${props.settings().splash}`,
|
||||
key: "splash",
|
||||
},
|
||||
@@ -701,16 +759,46 @@ export function RunSettingsBody(props: {
|
||||
category: "Terminal",
|
||||
display: "Monochrome UI",
|
||||
footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off",
|
||||
footerTone: saving() === "mono" ? "running" : "selection",
|
||||
keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`,
|
||||
key: "mono",
|
||||
},
|
||||
{
|
||||
category: "Terminal",
|
||||
display: "Work spinner",
|
||||
icon: (color) => (
|
||||
<OneCellSpinner
|
||||
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.settings().work_spinner]}
|
||||
color={color}
|
||||
animations={props.animations}
|
||||
glow={!props.mono}
|
||||
still={props.mono ? "*" : undefined}
|
||||
/>
|
||||
),
|
||||
footer:
|
||||
saving() === "work_spinner"
|
||||
? "saving"
|
||||
: props.settings().work_spinner.replace("block-", "").replaceAll("-", " "),
|
||||
footerTone: saving() === "work_spinner" ? "running" : "selection",
|
||||
keywords: `work spinner animation ${props.settings().work_spinner}`,
|
||||
key: "work_spinner",
|
||||
},
|
||||
])
|
||||
const change = (item: SettingEntry) => {
|
||||
const change = (item: SettingEntry, direction = 1) => {
|
||||
if (saving()) return
|
||||
const spinners = Config.MiniWorkSpinner.literals
|
||||
const next: MiniSettingChange =
|
||||
item.key === "mono"
|
||||
? { key: "mono", value: !props.settings().mono }
|
||||
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
||||
item.key === "work_spinner"
|
||||
? {
|
||||
key: "work_spinner",
|
||||
value:
|
||||
spinners[
|
||||
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
|
||||
]!,
|
||||
}
|
||||
: item.key === "mono"
|
||||
? { key: "mono", value: !props.settings().mono }
|
||||
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
||||
setSaving(item.key)
|
||||
void Promise.resolve(props.onChange(next))
|
||||
.catch(() => {})
|
||||
@@ -725,7 +813,7 @@ export function RunSettingsBody(props: {
|
||||
const name = event.name.toLowerCase()
|
||||
if (name !== "left" && name !== "right") return false
|
||||
event.preventDefault()
|
||||
if (item) change(item)
|
||||
if (item) change(item, name === "left" ? -1 : 1)
|
||||
return true
|
||||
},
|
||||
})
|
||||
@@ -733,6 +821,7 @@ export function RunSettingsBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Settings"
|
||||
layout={controller.layout()}
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
@@ -749,8 +838,9 @@ export function RunSettingsBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No settings found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -785,6 +875,12 @@ export function RunSubagentSelectBody(props: {
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
footerTone:
|
||||
item.status === "running" || item.status === "error"
|
||||
? item.status
|
||||
: item.status === "completed"
|
||||
? ("success" as const)
|
||||
: undefined,
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
@@ -810,6 +906,7 @@ export function RunSubagentSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select subagent"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -826,7 +923,8 @@ export function RunSubagentSelectBody(props: {
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No subagents found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -885,6 +983,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Queued prompts"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -901,7 +1000,8 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -943,6 +1043,7 @@ export function RunSkillSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Skills"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -957,8 +1058,9 @@ export function RunSkillSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -983,7 +1085,8 @@ export function RunVariantSelectBody(props: {
|
||||
{
|
||||
category: "",
|
||||
display: "Default",
|
||||
description: props.current() === undefined ? "current" : undefined,
|
||||
footer: props.current() === undefined ? "current" : undefined,
|
||||
footerTone: "selection",
|
||||
keywords: "default",
|
||||
variant: undefined,
|
||||
current: props.current() === undefined,
|
||||
@@ -991,7 +1094,8 @@ export function RunVariantSelectBody(props: {
|
||||
...props.variants().map((variant) => ({
|
||||
category: "",
|
||||
display: variant,
|
||||
description: props.current() === variant ? "current" : undefined,
|
||||
footer: props.current() === variant ? "current" : undefined,
|
||||
footerTone: "selection" as const,
|
||||
keywords: variant,
|
||||
variant,
|
||||
current: props.current() === variant,
|
||||
@@ -1008,6 +1112,7 @@ export function RunVariantSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select variant"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -1022,8 +1127,9 @@ export function RunVariantSelectBody(props: {
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
@@ -1066,6 +1172,7 @@ export function RunModelSelectBody(props: {
|
||||
category: provider.name,
|
||||
display: title,
|
||||
footer,
|
||||
footerTone: current ? ("selection" as const) : undefined,
|
||||
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
|
||||
current,
|
||||
}
|
||||
@@ -1096,6 +1203,7 @@ export function RunModelSelectBody(props: {
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select model"
|
||||
layout={controller.layout()}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -1104,24 +1212,26 @@ export function RunModelSelectBody(props: {
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
mono={props.mono}
|
||||
background={false}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={() =>
|
||||
controller.query().trim()
|
||||
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
|
||||
controller.query().trim() ||
|
||||
(controller.layout().compact && new Set(controller.items().map((item) => item.providerID)).size > 1)
|
||||
? controller.items().map((item) => ({ ...item, footer: item.providerName, footerTone: undefined }))
|
||||
: controller.items()
|
||||
}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
rows={controller.menu.limit}
|
||||
limit={controller.menu.limit()}
|
||||
compact={controller.layout().compact}
|
||||
empty={props.providers() ? "No results found" : "Models loading"}
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
mono={props.mono}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { BoxRenderable, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import {
|
||||
createFormBodyState,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import type { FormBodyState } from "./form.shared"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function RunFormBody(props: {
|
||||
request: MiniFormRequest
|
||||
@@ -45,6 +46,11 @@ export function RunFormBody(props: {
|
||||
onState?: (state: FormBodyState) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const [contentHeight, setContentHeight] = createSignal(1)
|
||||
const [viewportHeight, setViewportHeight] = createSignal(0)
|
||||
const compact = () => size().width < 56 || size().height < 12
|
||||
const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request))
|
||||
const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => {
|
||||
const value = typeof next === "function" ? next(state()) : next
|
||||
@@ -66,11 +72,39 @@ export function RunFormBody(props: {
|
||||
const custom = createMemo(() => formCustom(current()))
|
||||
const textual = createMemo(() => formTextual(current()))
|
||||
const multiple = createMemo(() => current()?.type === "multiselect")
|
||||
const editing = () => !unsupported() && !confirm() && (textual() || state().editing)
|
||||
const message = createMemo(() => {
|
||||
const value = props.request.metadata?.message
|
||||
return typeof value === "string" ? value : undefined
|
||||
})
|
||||
let area: TextareaRenderable | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const choices = new Map<number, BoxRenderable>()
|
||||
|
||||
const revealChoice = () => {
|
||||
const row = choices.get(state().selected)
|
||||
if (!scroll || scroll.isDestroyed || !row || row.isDestroyed || state().editing || confirm()) return
|
||||
if (row.y < scroll.viewport.y) scroll.scrollBy(row.y - scroll.viewport.y)
|
||||
const height = Math.min(row.height, scroll.viewport.height)
|
||||
if (row.y + height > scroll.viewport.y + scroll.viewport.height)
|
||||
scroll.scrollBy(row.y + height - scroll.viewport.y - scroll.viewport.height)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
state().field
|
||||
state().selected
|
||||
size()
|
||||
revealChoice()
|
||||
})
|
||||
|
||||
const action = createMemo(() => {
|
||||
if (confirm()) return "submit"
|
||||
if (textual() || state().editing) return "save"
|
||||
const field = externalField()
|
||||
if (!field) return "choose"
|
||||
if (state().answers[field.key] === true) return formSingle(props.request) ? "submit" : "next"
|
||||
return state().externalReady[field.key] ? (size().width < 24 ? "done" : "acknowledge") : "open URL"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setState((previous) => formSync(previous, props.request))
|
||||
@@ -143,6 +177,12 @@ export function RunFormBody(props: {
|
||||
|
||||
const choose = (selected = state().selected) => {
|
||||
const base = formSetSelected(state(), selected)
|
||||
const row = choices.get(selected)
|
||||
if (scroll && row && (row.y < scroll.viewport.y || row.y >= scroll.viewport.y + scroll.viewport.height)) {
|
||||
setState(base)
|
||||
revealChoice()
|
||||
return
|
||||
}
|
||||
const next = formPick(base, props.request)
|
||||
setState(next)
|
||||
if (next.editing || multiple()) return
|
||||
@@ -208,6 +248,11 @@ export function RunFormBody(props: {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (unsupported()) return
|
||||
if (state().editing) return
|
||||
if (
|
||||
@@ -256,178 +301,346 @@ export function RunFormBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>{props.mono ? "*" : "◆"}</text>
|
||||
<text fg={props.theme.text}>{props.request.title}</text>
|
||||
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||
<text fg={props.theme.muted}>
|
||||
{confirm()
|
||||
? "Review"
|
||||
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={message()}>{(value) => <text fg={props.theme.muted}>{value()}</text>}</Show>
|
||||
<Show when={unsupported()}>
|
||||
{(value) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.warning} wrapMode="word">
|
||||
{value()}
|
||||
</text>
|
||||
<text fg={props.theme.muted}>This request remains pending until you dismiss it.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && externalField()}>
|
||||
{(field) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.text}>{field().description ?? formLabel(field())}</text>
|
||||
<text fg={props.theme.highlight} wrapMode="word">
|
||||
{field().url}
|
||||
</text>
|
||||
<text fg={props.theme.muted}>
|
||||
{state().answers[field().key] === true
|
||||
? "Acknowledged"
|
||||
: state().externalReady[field().key]
|
||||
? "Press enter to acknowledge completion"
|
||||
: "Press enter to open the URL"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && answerField() && !confirm()}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multiple() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
<Show when={textual() || state().editing}>
|
||||
<textarea
|
||||
ref={(item: TextareaRenderable) => {
|
||||
area = item
|
||||
}}
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
initialValue={formInput(state(), current())}
|
||||
placeholder={formPlaceholder(answerField())}
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused
|
||||
onSubmit={commitInput}
|
||||
onContentChange={() => {
|
||||
const currentArea = area
|
||||
if (!currentArea || currentArea.isDestroyed) return
|
||||
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
void cancel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!textual() && !state().editing}>
|
||||
<box flexDirection="column">
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const picked = () => {
|
||||
const field = current()
|
||||
if (!field) return false
|
||||
const value = state().answers[field.key]
|
||||
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
|
||||
}
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||
onMouseUp={() => choose(index())}
|
||||
>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>
|
||||
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
|
||||
</text>
|
||||
<text fg={active() ? props.theme.text : props.theme.muted}>
|
||||
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||
{row.label}
|
||||
{!multiple() && picked() ? " *" : ""}
|
||||
</text>
|
||||
<Show when={row.description}>
|
||||
<text fg={props.theme.muted}>{row.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
|
||||
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
|
||||
{props.mono
|
||||
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
|
||||
: `${rows().length + 1}.`}
|
||||
</text>
|
||||
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
|
||||
Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!unsupported() && confirm()}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.request.fields}>
|
||||
{(field) => (
|
||||
<text fg={props.theme.muted} wrapMode="none" truncate>
|
||||
{formLabel(field)}:{" "}
|
||||
{field.type === "external"
|
||||
? state().answers[field.key] === true
|
||||
? "acknowledged"
|
||||
: "required"
|
||||
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={props.theme.muted}>
|
||||
{state().submitting
|
||||
? "submitting..."
|
||||
: unsupported()
|
||||
? "esc dismiss"
|
||||
: confirm()
|
||||
? "enter submit esc dismiss"
|
||||
: textual() || state().editing
|
||||
? "enter save esc dismiss"
|
||||
: props.mono
|
||||
? "up/down select enter choose tab next esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme.surface}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<box height={1} flexDirection="row" gap={1} flexShrink={0} marginBottom={compact() ? 0 : 1}>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.question} wrapMode="none" flexShrink={0}>
|
||||
{props.mono ? "*" : "◆"}
|
||||
</text>
|
||||
<Show when={state().error}>
|
||||
<text fg={props.theme.error} wrapMode="none" truncate>
|
||||
{state().error}
|
||||
<text fg={props.theme.text} wrapMode="none" truncate minWidth={0}>
|
||||
{props.request.title}
|
||||
</text>
|
||||
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||
<text fg={props.theme.muted} wrapMode="none" flexShrink={0}>
|
||||
{confirm()
|
||||
? "Review"
|
||||
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height={!compact() && editing() ? Math.min(contentHeight(), Math.max(1, size().height - 9)) : undefined}
|
||||
flexGrow={!compact() && editing() ? 0 : 1}
|
||||
minHeight={0}
|
||||
viewportOptions={{
|
||||
paddingRight: props.mono ? 0 : 1,
|
||||
onSizeChange() {
|
||||
setViewportHeight(this.height)
|
||||
},
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
visible: !props.mono && contentHeight() > viewportHeight(),
|
||||
trackOptions: { backgroundColor: props.theme.surface, foregroundColor: props.theme.line },
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
gap={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setContentHeight(this.height)
|
||||
}}
|
||||
>
|
||||
<Show when={message()}>
|
||||
{(value) => (
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={unsupported()}>
|
||||
{(value) => (
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.warning} wrapMode="word" flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
This request remains pending until you dismiss it.
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && externalField()}>
|
||||
{(field) => (
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{field().description ?? formLabel(field())}
|
||||
</text>
|
||||
<text width="100%" fg={props.theme.link} wrapMode="word" flexShrink={0}>
|
||||
{field().url}
|
||||
</text>
|
||||
<text
|
||||
width="100%"
|
||||
fg={state().answers[field().key] === true ? props.theme.selection : props.theme.muted}
|
||||
flexShrink={0}
|
||||
>
|
||||
{state().answers[field().key] === true
|
||||
? "Acknowledged"
|
||||
: state().externalReady[field().key]
|
||||
? "Press enter to acknowledge completion"
|
||||
: "Press enter to open the URL"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!unsupported() && answerField() && !confirm()}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multiple() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
<Show when={!textual() && !state().editing}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0}>
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const picked = () => {
|
||||
const field = current()
|
||||
if (!field) return false
|
||||
const value = state().answers[field.key]
|
||||
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
|
||||
}
|
||||
const ordinal = () => (props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`)
|
||||
const inline = () =>
|
||||
!!row.description &&
|
||||
stringWidth(ordinal()) +
|
||||
2 +
|
||||
stringWidth(row.label) +
|
||||
(multiple() ? 4 : picked() ? 2 : 0) +
|
||||
stringWidth(row.description) <=
|
||||
size().width - (compact() ? 0 : 5) - (props.mono ? 0 : 1)
|
||||
return (
|
||||
<box
|
||||
ref={(item) => {
|
||||
choices.set(index(), item)
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="flex-start"
|
||||
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||
backgroundColor={active() ? props.theme.formfieldFocusedBg : "transparent"}
|
||||
onMouseUp={() => choose(index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{ordinal()}
|
||||
</text>
|
||||
<box
|
||||
flexDirection={inline() ? "row" : "column"}
|
||||
gap={inline() ? 1 : 0}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
>
|
||||
<text
|
||||
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
|
||||
wrapMode="word"
|
||||
flexShrink={0}
|
||||
>
|
||||
<span style={{ fg: picked() ? props.theme.selection : undefined }}>
|
||||
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||
</span>
|
||||
{row.label}
|
||||
<span style={{ fg: props.theme.selection }}>{!multiple() && picked() ? " *" : ""}</span>
|
||||
</text>
|
||||
<Show when={row.description}>
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{row.description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box
|
||||
ref={(item) => {
|
||||
choices.set(rows().length, item)
|
||||
}}
|
||||
onSizeChange={revealChoice}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={
|
||||
state().selected === rows().length ? props.theme.formfieldFocusedBg : "transparent"
|
||||
}
|
||||
onMouseUp={() => choose(rows().length)}
|
||||
>
|
||||
<text
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
fg={
|
||||
state().selected === rows().length
|
||||
? props.theme.formfieldFocusedText
|
||||
: props.theme.formfieldText
|
||||
}
|
||||
>
|
||||
{props.mono
|
||||
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
|
||||
: `${rows().length + 1}.`}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="word"
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
fg={
|
||||
state().selected === rows().length
|
||||
? props.theme.formfieldFocusedText
|
||||
: props.theme.formfieldText
|
||||
}
|
||||
>
|
||||
Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!unsupported() && confirm()}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0}>
|
||||
<For each={props.request.fields}>
|
||||
{(field) => (
|
||||
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{formLabel(field)}:{" "}
|
||||
{field.type === "external"
|
||||
? state().answers[field.key] === true
|
||||
? "acknowledged"
|
||||
: "required"
|
||||
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={stringWidth(state().error) > size().width || state().error.includes("\n")}>
|
||||
<text width="100%" fg={props.theme.error} wrapMode="word" flexShrink={0}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={!unsupported() && answerField() && editing()}>
|
||||
<textarea
|
||||
ref={(item: TextareaRenderable) => {
|
||||
area = item
|
||||
}}
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={Math.max(1, Math.min(3, size().height - 6))}
|
||||
flexShrink={0}
|
||||
marginTop={compact() ? 0 : 1}
|
||||
initialValue={formInput(state(), current())}
|
||||
placeholder={formPlaceholder(answerField())}
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.formfieldText}
|
||||
focusedTextColor={props.theme.formfieldFocusedText}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.formfieldFocusedBg}
|
||||
cursorColor={props.theme.formfieldFocusedText}
|
||||
focused
|
||||
onSubmit={commitInput}
|
||||
onContentChange={() => {
|
||||
const currentArea = area
|
||||
if (!currentArea || currentArea.isDestroyed) return
|
||||
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
void cancel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state().error && compact()}>
|
||||
<text height={1} fg={props.theme.error} wrapMode="none" truncate flexShrink={0}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!compact() && editing()}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</Show>
|
||||
<Show
|
||||
when={compact()}
|
||||
fallback={
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text
|
||||
fg={state().submitting ? props.theme.running : props.theme.muted}
|
||||
wrapMode="word"
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
>
|
||||
{state().submitting
|
||||
? "submitting..."
|
||||
: unsupported()
|
||||
? "esc dismiss"
|
||||
: confirm()
|
||||
? "enter submit esc dismiss"
|
||||
: editing()
|
||||
? "enter save esc dismiss"
|
||||
: externalField()
|
||||
? `enter ${action()} esc dismiss`
|
||||
: props.mono
|
||||
? "up/down select enter choose tab next esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
</text>
|
||||
<Show when={state().error}>
|
||||
<text fg={props.theme.error} wrapMode="none" truncate flexShrink={1}>
|
||||
{state().error}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<Show when={!unsupported()}>
|
||||
<text
|
||||
height={1}
|
||||
fg={state().submitting ? props.theme.running : props.theme.muted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{state().submitting ? "submitting..." : `enter ${action()}`}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!state().submitting}>
|
||||
<text height={1} fg={props.theme.muted} wrapMode="none" flexShrink={0}>
|
||||
esc dismiss
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!state().submitting && size().height >= 10}>
|
||||
<text fg={props.theme.muted} wrapMode="word" maxWidth="100%" flexShrink={0}>
|
||||
{size().width >= 56 && rows().length > 0 && !state().editing ? "up/down select tab next " : ""}pgup/pgdn
|
||||
scroll
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
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"
|
||||
import { monoTruncate } from "./mono"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
export const FOOTER_COMPACT_WIDTH = 40
|
||||
|
||||
export function footerMenuText(text: string, width: number, mono = false) {
|
||||
if (!mono) return Locale.truncateWidth(text, width)
|
||||
if (stringWidth(text) <= width) return text
|
||||
const suffix = ".".repeat(Math.min(3, Math.max(0, width)))
|
||||
return Locale.takeWidth(text, width - suffix.length) + suffix
|
||||
}
|
||||
|
||||
export type RunFooterMenuItem = {
|
||||
display: string
|
||||
icon?: (color: ColorInput) => JSX.Element
|
||||
current?: boolean
|
||||
description?: string
|
||||
descriptionTone?: "selection"
|
||||
category?: string
|
||||
footer?: string
|
||||
footerTone?: "selection" | "running" | "error" | "success"
|
||||
}
|
||||
|
||||
type RunFooterMenuRow =
|
||||
@@ -22,10 +33,10 @@ type RunFooterMenuRow =
|
||||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number | Accessor<number> }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const limit = () => input.limit ?? FOOTER_MENU_ROWS
|
||||
const limit = () => Math.max(1, typeof input.limit === "function" ? input.limit() : (input.limit ?? FOOTER_MENU_ROWS))
|
||||
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
|
||||
|
||||
const reveal = (index: number) => {
|
||||
@@ -58,6 +69,7 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
||||
selected,
|
||||
offset,
|
||||
rows,
|
||||
limit,
|
||||
reveal,
|
||||
reset,
|
||||
move,
|
||||
@@ -76,13 +88,17 @@ export function RunFooterMenu(props: {
|
||||
paddingLeft?: number
|
||||
paddingRight?: number
|
||||
grouped?: boolean
|
||||
compact?: boolean
|
||||
background?: boolean
|
||||
headerColor?: ColorInput
|
||||
mono?: boolean
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
||||
const limit = () => Math.max(1, Math.min(props.rows(), props.limit ?? FOOTER_MENU_ROWS))
|
||||
const border = () => props.border ?? true
|
||||
const paddingLeft = () => Math.min(props.paddingLeft ?? 1, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
|
||||
const paddingRight = () => Math.min(props.paddingRight ?? 0, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
|
||||
const width = () => Math.max(0, term().width - (border() ? 1 : 0) - paddingLeft() - paddingRight())
|
||||
const [groupOffset, setGroupOffset] = createSignal(0)
|
||||
let previous = -1
|
||||
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
|
||||
@@ -90,12 +106,12 @@ export function RunFooterMenu(props: {
|
||||
let category = ""
|
||||
props.items().forEach((item, index) => {
|
||||
if (item.category && item.category !== category) {
|
||||
if (all.length > 0) {
|
||||
if (all.length > 0 && !props.compact) {
|
||||
all.push({ type: "spacer" })
|
||||
}
|
||||
|
||||
category = item.category
|
||||
all.push({ type: "header", label: item.category })
|
||||
if (!props.compact) all.push({ type: "header", label: item.category })
|
||||
}
|
||||
|
||||
all.push({ type: "item", item, index })
|
||||
@@ -151,34 +167,11 @@ export function RunFooterMenu(props: {
|
||||
)
|
||||
return width === 0 ? 0 : width + 2
|
||||
})
|
||||
const descriptionPad = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
|
||||
}
|
||||
const descriptionText = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return
|
||||
}
|
||||
|
||||
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
|
||||
const available =
|
||||
term().width -
|
||||
(border() ? 1 : 0) -
|
||||
(props.paddingLeft ?? 1) -
|
||||
(props.paddingRight ?? 0) -
|
||||
descriptionColumn() -
|
||||
footerWidth -
|
||||
4
|
||||
const width = Math.max(12, available)
|
||||
return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
height={props.rows()}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
flexDirection="column"
|
||||
>
|
||||
@@ -196,8 +189,8 @@ export function RunFooterMenu(props: {
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
paddingLeft={paddingLeft()}
|
||||
paddingRight={paddingRight()}
|
||||
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||
>
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||
@@ -213,9 +206,9 @@ export function RunFooterMenu(props: {
|
||||
|
||||
if (row.type === "header") {
|
||||
return (
|
||||
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
|
||||
<box height={1} flexShrink={0} paddingLeft={paddingLeft()} paddingRight={paddingRight()}>
|
||||
<text
|
||||
fg={props.headerColor ?? props.theme().highlight}
|
||||
fg={props.headerColor ?? props.theme().muted}
|
||||
attributes={TextAttributes.BOLD}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
@@ -227,71 +220,91 @@ export function RunFooterMenu(props: {
|
||||
}
|
||||
|
||||
const active = () => row.index === props.selected()
|
||||
const available = () => Math.max(0, width() - (row.item.icon ? 2 : 0))
|
||||
const attributes = () =>
|
||||
active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined
|
||||
const background = () =>
|
||||
active()
|
||||
? props.background
|
||||
? props.theme().selected
|
||||
: props.theme().shade
|
||||
: props.background
|
||||
? props.theme().shade
|
||||
: transparent
|
||||
active() ? props.theme().actionFocusedBg : props.background ? props.theme().shade : transparent
|
||||
const footer = () => {
|
||||
if (!row.item.footer) return
|
||||
const title = stringWidth(row.item.display)
|
||||
const primary = row.item.footerTone && !(row.item.current && row.item.footerTone === "selection")
|
||||
return (primary ? Math.min(row.item.icon ? 4 : 8, title) : title) + 1 + stringWidth(row.item.footer) <=
|
||||
available()
|
||||
? row.item.footer
|
||||
: undefined
|
||||
}
|
||||
const description = () => {
|
||||
if (!row.item.description) return
|
||||
const remaining = available() - descriptionColumn() - (footer() ? stringWidth(footer()!) + 1 : 0)
|
||||
if (remaining < Math.min(12, stringWidth(row.item.description))) return
|
||||
return footerMenuText(row.item.description, remaining, props.mono)
|
||||
}
|
||||
return (
|
||||
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
<box height={1} flexShrink={0} paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
{border() ? (
|
||||
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
|
||||
<text fg={props.theme().actionFocusedText} bg={background()} wrapMode="none">
|
||||
{active() ? (props.mono ? ">" : "▌") : " "}
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
paddingLeft={props.paddingLeft ?? 1}
|
||||
paddingRight={props.paddingRight ?? 0}
|
||||
paddingLeft={paddingLeft()}
|
||||
paddingRight={paddingRight()}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
|
||||
{row.item.icon ? (
|
||||
<box width={2} flexShrink={0}>
|
||||
{row.item.icon(active() ? props.theme().actionFocusedText : props.theme().formfieldText)}
|
||||
</box>
|
||||
) : undefined}
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().text}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme().formfieldText}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.display}
|
||||
{footerMenuText(
|
||||
row.item.display,
|
||||
available() - (footer() ? stringWidth(footer()!) + 1 : 0),
|
||||
props.mono,
|
||||
)}
|
||||
</text>
|
||||
{row.item.description ? (
|
||||
{description() ? (
|
||||
<>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{descriptionPad(row.item)}
|
||||
{" ".repeat(Math.max(1, descriptionColumn() - stringWidth(row.item.display)))}
|
||||
</text>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={
|
||||
active()
|
||||
? props.theme().actionFocusedText
|
||||
: props.theme()[row.item.descriptionTone ?? "muted"]
|
||||
}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{descriptionText(row.item)}
|
||||
{description()}
|
||||
</text>
|
||||
</>
|
||||
) : undefined}
|
||||
</box>
|
||||
{row.item.footer ? (
|
||||
{footer() ? (
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
fg={active() ? props.theme().actionFocusedText : props.theme()[row.item.footerTone ?? "muted"]}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.item.footer}
|
||||
{footer()}
|
||||
</text>
|
||||
) : undefined}
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
|
||||
import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
permissionShift,
|
||||
type PermissionOption,
|
||||
} from "./permission.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
@@ -44,13 +44,16 @@ function buttons(
|
||||
mono: boolean,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<For each={list}>
|
||||
{(option) => (
|
||||
<box
|
||||
width={stringWidth(permissionLabel(option)) + 2}
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === selected ? theme.highlight : transparent}
|
||||
backgroundColor={option === selected ? theme.actionFocusedBg : transparent}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) onHover(option)
|
||||
}}
|
||||
@@ -59,7 +62,8 @@ function buttons(
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={option === selected ? theme.surface : theme.muted}
|
||||
wrapMode="none"
|
||||
fg={option === selected ? theme.actionFocusedText : theme.actionSecondaryText}
|
||||
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
|
||||
>
|
||||
{permissionLabel(option)}
|
||||
@@ -108,11 +112,11 @@ export function RejectField(props: {
|
||||
wrapMode="word"
|
||||
placeholder="Tell OpenCode what to do differently"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
textColor={props.theme.formfieldText}
|
||||
focusedTextColor={props.theme.formfieldFocusedText}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focusedBackgroundColor={props.theme.formfieldFocusedBg}
|
||||
cursorColor={props.theme.formfieldFocusedText}
|
||||
focused={!props.disabled}
|
||||
onSubmit={props.onConfirm}
|
||||
onContentChange={() => {
|
||||
@@ -144,10 +148,14 @@ export function RunPermissionBody(props: {
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const width = () => size().width
|
||||
const compact = () => width() < 56 || size().height < 12
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request))
|
||||
const stage = createMemo(() => state().stage)
|
||||
const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const scrollbar = createMemo(() => ({
|
||||
visible: !props.mono,
|
||||
trackOptions: {
|
||||
@@ -156,19 +164,25 @@ export function RunPermissionBody(props: {
|
||||
},
|
||||
}))
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
permissionOptions(stage()).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
)
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const controlsWidth = () => opts().reduce((total, option) => total + stringWidth(permissionLabel(option)) + 3, -1)
|
||||
const hint = () =>
|
||||
compact() && width() < 56
|
||||
? "pgup/pgdn scroll"
|
||||
: `${props.mono ? "left/right" : "⇆"} select enter confirm esc ${stage() === "always" ? "cancel" : "reject"}`
|
||||
const inlineControls = () => controlsWidth() + stringWidth(hint()) + 1 <= width() - (compact() ? 0 : 5)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
if (stage() === "always") {
|
||||
return "Always allow"
|
||||
}
|
||||
|
||||
if (state().stage === "reject") {
|
||||
return "Reject permission"
|
||||
if (stage() === "reject") {
|
||||
return width() < 24 ? "Reject" : "Reject permission"
|
||||
}
|
||||
|
||||
return "Permission required"
|
||||
return width() < 24 ? "Permission" : "Permission required"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -184,6 +198,12 @@ export function RunPermissionBody(props: {
|
||||
setState((prev) => permissionShift(prev, dir, opts()))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
stage()
|
||||
props.request.id
|
||||
if (scroll && !scroll.isDestroyed) scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -233,6 +253,12 @@ export function RunPermissionBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.submitting) {
|
||||
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
|
||||
event.preventDefault()
|
||||
@@ -273,58 +299,50 @@ export function RunPermissionBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme.surface}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
gap={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
|
||||
{props.mono ? "!" : "△"}
|
||||
</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2}>
|
||||
<text fg={props.theme.muted} flexShrink={0}>
|
||||
{info().icon}
|
||||
</text>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state().stage === "reject"}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text height={1} fg={props.theme.text} wrapMode="none" truncate>
|
||||
<span style={{ fg: props.theme.permission }}>{props.mono ? "! " : "△ "}</span>
|
||||
{title()}
|
||||
</text>
|
||||
<Show when={!compact() && stage() === "reject"}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={state().stage !== "reject"}
|
||||
when={stage() !== "reject"}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
|
||||
<box width="100%" flexGrow={1} minHeight={0} justifyContent="flex-end">
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.line}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
flexDirection={width() >= 80 ? "row" : "column"}
|
||||
alignItems={width() >= 80 ? "center" : "stretch"}
|
||||
justifyContent="space-between"
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
gap={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
|
||||
<box width={width() >= 80 ? undefined : "100%"} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<RejectField
|
||||
theme={props.theme}
|
||||
text={state().message}
|
||||
@@ -342,17 +360,17 @@ export function RunPermissionBody(props: {
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
|
||||
{compact() ? "Waiting..." : "Waiting for permission event..."}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={compact() ? 1 : 2} flexShrink={0}>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{compact() ? "reject" : "confirm"}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{compact() ? "back" : "cancel"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -360,21 +378,46 @@ export function RunPermissionBody(props: {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
paddingLeft={compact() ? 0 : 1}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
viewportOptions={{
|
||||
paddingLeft: compact() ? 0 : 1,
|
||||
paddingRight: props.mono ? 0 : 1,
|
||||
}}
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={stage() === "permission"}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<box width="100%" paddingLeft={compact() ? 0 : 1} flexShrink={0}>
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
<span style={{ fg: props.theme.muted }}>{info().icon} </span>
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<Show
|
||||
when={info().patch}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
@@ -386,13 +429,16 @@ export function RunPermissionBody(props: {
|
||||
<Show
|
||||
when={props.block.syntax}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
{patch()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(syntax) => (
|
||||
<code
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
wrapMode="word"
|
||||
filetype="diff"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
@@ -406,93 +452,107 @@ export function RunPermissionBody(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
<Show
|
||||
when={width() >= 40}
|
||||
fallback={
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{info().diff}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
<text width="100%" fg={props.theme.muted} flexShrink={0}>
|
||||
No diff provided
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
width="100%"
|
||||
flexDirection={inlineControls() ? "row" : "column"}
|
||||
justifyContent="space-between"
|
||||
gap={compact() ? 0 : 1}
|
||||
paddingLeft={compact() ? 0 : 2}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.pane}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
props.mono ?? false,
|
||||
)}
|
||||
<box width={inlineControls() ? controlsWidth() : "100%"} flexShrink={0}>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
props.mono ?? false,
|
||||
)}
|
||||
</box>
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
|
||||
{compact() ? "Waiting..." : "Waiting for permission event..."}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
|
||||
<Show
|
||||
when={compact() && width() < 56}
|
||||
fallback={
|
||||
<>
|
||||
{props.mono ? "left/right" : "⇆"}
|
||||
<span style={{ fg: props.theme.muted }}>{" select "}</span>
|
||||
enter<span style={{ fg: props.theme.muted }}>{" confirm "}</span>
|
||||
esc<span style={{ fg: props.theme.muted }}> {stage() === "always" ? "cancel" : "reject"}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
pgup/pgdn<span style={{ fg: props.theme.muted }}> scroll</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -5,15 +5,38 @@
|
||||
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||
// composer while the footer view renders any active menus below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import {
|
||||
StyledText,
|
||||
decodePasteBytes,
|
||||
fg,
|
||||
stripAnsiSequences,
|
||||
type ColorInput,
|
||||
type KeyEvent,
|
||||
type PasteEvent,
|
||||
type TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { normalizePromptContent } from "../prompt/content"
|
||||
import { deduplicatePromptImages, promptAttachmentLabel } from "../prompt/attachment"
|
||||
import { resolvePastedAttachments } from "../component/prompt/local-attachment"
|
||||
import { createTuiClipboard, type OwnedClipboardService } from "../clipboard"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { errorMessage } from "../util/error"
|
||||
import {
|
||||
createPromptHistory,
|
||||
displayCharAt,
|
||||
@@ -30,8 +53,7 @@ import {
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { FOOTER_COMPACT_WIDTH, FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
@@ -44,13 +66,31 @@ import type {
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { EmptyBorder } from "../ui/border"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS
|
||||
|
||||
export function footerPromptLayout(
|
||||
height: number,
|
||||
lines = TEXTAREA_MIN_ROWS,
|
||||
options = 0,
|
||||
statusRows = 1,
|
||||
images = false,
|
||||
) {
|
||||
const padding = height >= PROMPT_MAX_ROWS + 4 ? 1 : 0
|
||||
// Reserve status and, where possible, one transcript row.
|
||||
const available = Math.max(1, height - 1 - statusRows - padding * 2)
|
||||
const preview = images && options === 0 ? Math.min(4, available - Math.min(TEXTAREA_MAX_ROWS, Math.max(1, lines))) : 0
|
||||
const imageRows = preview >= 3 ? preview : 0
|
||||
const textarea = Math.max(1, Math.min(TEXTAREA_MAX_ROWS, available - imageRows - (options > 0 ? 1 : 0)))
|
||||
const rows = Math.min(textarea, Math.max(1, lines))
|
||||
const menu = options > 0 ? Math.max(1, Math.min(AUTOCOMPLETE_ROWS, options, available - rows)) : 0
|
||||
return { padding, textarea, menu, images: imageRows, rows: rows + menu + imageRows }
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
|
||||
|
||||
@@ -86,8 +126,11 @@ type PromptInput = {
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
width: Accessor<number>
|
||||
statusRows: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
mono: Accessor<boolean>
|
||||
imagePreview?: boolean
|
||||
clipboard?: Pick<ClipboardService, "read">
|
||||
history?: Accessor<RunPrompt[]>
|
||||
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
onQueuedPromptSteer: (inboxID: string) => Promise<boolean>
|
||||
@@ -112,20 +155,20 @@ export type PromptState = {
|
||||
selected: Accessor<number>
|
||||
offset: Accessor<number>
|
||||
rows: Accessor<number>
|
||||
images: Accessor<ReadonlyArray<{ uri: string }>>
|
||||
layout: Accessor<ReturnType<typeof footerPromptLayout>>
|
||||
requestExit: () => boolean
|
||||
onSubmit: () => void
|
||||
submitText: (text: string) => void
|
||||
openEditor: (input?: { value?: string }) => Promise<void>
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
function clamp(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function emptyPrompt(shell: boolean): RunPrompt {
|
||||
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||
}
|
||||
@@ -177,11 +220,17 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
|
||||
export function RunPromptBody(props: {
|
||||
theme: () => RunFooterTheme
|
||||
background: () => ColorInput
|
||||
rail: () => ColorInput
|
||||
mono: boolean
|
||||
cursorStyle: RunTuiConfig["cursor"]
|
||||
placeholder: () => StyledText | string
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
images: Accessor<ReadonlyArray<{ uri: string }>>
|
||||
layout: Accessor<ReturnType<typeof footerPromptLayout>>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
@@ -227,12 +276,53 @@ export function RunPromptBody(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%">
|
||||
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
|
||||
<box width="100%" paddingTop={props.layout().padding} paddingBottom={props.layout().padding}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={props.rail()}
|
||||
customBorderChars={{ ...EmptyBorder, vertical: props.mono ? "|" : "┃" }}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
onSizeChange={props.onSizeChange}
|
||||
>
|
||||
<Show when={props.layout().images > 0}>
|
||||
<box width="100%" height={props.layout().images} flexDirection="row" gap={1}>
|
||||
<For
|
||||
each={props
|
||||
.images()
|
||||
.slice(0, 3)
|
||||
.map((image) => image.uri)}
|
||||
>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box width={props.layout().images * 2} height="100%" flexShrink={1}>
|
||||
<Show when={!failed()} fallback={<text fg={props.theme().muted}>No preview</text>}>
|
||||
<image
|
||||
id={`mini-prompt-image-${index()}`}
|
||||
source={image}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.images().length > 3}>
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||
+{props.images().length - 3} more
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<textarea
|
||||
width="100%"
|
||||
minHeight={TEXTAREA_MIN_ROWS}
|
||||
maxHeight={TEXTAREA_MAX_ROWS}
|
||||
maxHeight={props.layout().textarea}
|
||||
wrapMode="word"
|
||||
placeholder={props.placeholder()}
|
||||
placeholderColor={props.theme().muted}
|
||||
@@ -244,8 +334,8 @@ export function RunPromptBody(props: {
|
||||
cursorStyle={props.cursorStyle}
|
||||
onSubmit={props.onSubmit}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onPaste={() => {
|
||||
refreshPasteLayout()
|
||||
onPaste={(event) => {
|
||||
void props.onPaste(event).finally(refreshPasteLayout)
|
||||
}}
|
||||
onContentChange={props.onContentChange}
|
||||
ref={(next) => {
|
||||
@@ -258,6 +348,10 @@ export function RunPromptBody(props: {
|
||||
}
|
||||
|
||||
export function createPromptState(input: PromptInput): PromptState {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const [lines, setLines] = createSignal(TEXTAREA_MIN_ROWS)
|
||||
const [statusRows, setStatusRows] = createSignal(1)
|
||||
const [shell, setShell] = createSignal(false)
|
||||
const placeholder = createMemo(() => {
|
||||
if (shell()) {
|
||||
@@ -268,7 +362,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return ""
|
||||
}
|
||||
|
||||
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
|
||||
return new StyledText([
|
||||
fg(input.theme().muted)(`Ask anything, / for commands, @ for context${input.mono() ? "..." : "…"}`),
|
||||
])
|
||||
})
|
||||
|
||||
let history = createPromptHistory(input.history?.())
|
||||
@@ -283,6 +379,31 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
let type = 0
|
||||
let parts: Mention[] = []
|
||||
let marks = new Map<number, number>()
|
||||
const [draftParts, setDraftParts] = createSignal<RunPromptPart[]>([])
|
||||
const attachments = createMemo(() =>
|
||||
draftParts().flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
description: part.description,
|
||||
mention: part.source?.text
|
||||
? { start: part.source.text.start, end: part.source.text.end, text: part.source.text.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
const images = createMemo(() =>
|
||||
(deduplicatePromptImages(attachments()) ?? []).filter((file) => file.uri.startsWith("data:image/")),
|
||||
)
|
||||
let clipboard: OwnedClipboardService | undefined
|
||||
let pasteQueue: Promise<void> | undefined
|
||||
let applyingPaste = false
|
||||
let disposed = false
|
||||
let revision = 0
|
||||
|
||||
const [mode, setMode] = createSignal<MenuMode>(false)
|
||||
const [at, setAt] = createSignal(0)
|
||||
@@ -290,11 +411,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
const visible = createMemo(() => mode() !== false)
|
||||
|
||||
const setShellMode = (value: boolean) => {
|
||||
revision += 1
|
||||
setShell(value)
|
||||
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
|
||||
}
|
||||
|
||||
const width = createMemo(() => Math.max(20, input.width() - 8))
|
||||
const width = createMemo(() => Math.max(0, input.width() - (input.width() < FOOTER_COMPACT_WIDTH ? 2 : 4)))
|
||||
const agents = createMemo<Auto[]>(() => {
|
||||
return input
|
||||
.agents()
|
||||
@@ -317,9 +439,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
const references = createMemo<Auto[]>(() => {
|
||||
return input.references().map((item) => ({
|
||||
kind: "mention",
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + item.name, width(), true)
|
||||
: Locale.truncateMiddle("@" + item.name, width()),
|
||||
display: "@" + item.name,
|
||||
value: item.name,
|
||||
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
|
||||
part: {
|
||||
@@ -339,7 +459,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
}))
|
||||
})
|
||||
const [files] = createResource(
|
||||
const [fileResults] = createResource(
|
||||
query,
|
||||
async (value) => {
|
||||
if (!visible() || mode() !== "mention") {
|
||||
@@ -361,9 +481,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
return {
|
||||
kind: "mention",
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + filename, width(), true)
|
||||
: Locale.truncateMiddle("@" + filename, width()),
|
||||
display: "@" + filename,
|
||||
value: filename,
|
||||
directory: item.endsWith("/"),
|
||||
part: {
|
||||
@@ -386,6 +504,15 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
{ initialValue: [] as Auto[] },
|
||||
)
|
||||
const files = createMemo(() =>
|
||||
fileResults().map((item) => {
|
||||
const parts = item.value.split("/")
|
||||
const paths = parts
|
||||
.slice(0, item.directory ? -1 : undefined)
|
||||
.map((_, index) => "@" + parts.slice(index).join("/"))
|
||||
return { ...item, display: paths.find((value) => stringWidth(value) <= width()) ?? paths.at(-1) ?? item.display }
|
||||
}),
|
||||
)
|
||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
|
||||
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const skillOptions = createMemo<SkillOption[]>(() =>
|
||||
@@ -482,10 +609,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
})
|
||||
.map((item) => item.obj)
|
||||
})
|
||||
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
|
||||
const popup = createMemo(() => {
|
||||
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
|
||||
const layout = createMemo(() => {
|
||||
term()
|
||||
return footerPromptLayout(
|
||||
renderer.terminalHeight,
|
||||
lines(),
|
||||
visible() ? Math.max(1, options().length) : 0,
|
||||
statusRows(),
|
||||
input.imagePreview === true && !input.mono() && !shell() && images().length > 0,
|
||||
)
|
||||
})
|
||||
const menu = createFooterMenuState({ count: () => options().length, limit: () => Math.max(1, layout().menu) })
|
||||
|
||||
const hide = () => {
|
||||
setMode(false)
|
||||
@@ -498,7 +632,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
|
||||
setLines(Math.max(area.lineCount, area.virtualLineCount))
|
||||
area.maxHeight = layout().textarea
|
||||
input.onRows(layout().rows)
|
||||
}
|
||||
|
||||
const scheduleRows = () => {
|
||||
@@ -518,8 +654,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
const next: Mention[] = []
|
||||
const map = new Map<number, number>()
|
||||
const next = parts.map<Mention | undefined>((part) =>
|
||||
part.type === "file" && !part.source?.text ? part : undefined,
|
||||
)
|
||||
let tracked = 0
|
||||
for (const item of area.extmarks.getAllForTypeId(type)) {
|
||||
const idx = marks.get(item.id)
|
||||
if (idx === undefined) {
|
||||
@@ -556,15 +694,15 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
copy.source.text.value = text
|
||||
}
|
||||
|
||||
map.set(item.id, next.length)
|
||||
next.push(copy)
|
||||
tracked += 1
|
||||
next[idx] = copy
|
||||
}
|
||||
|
||||
const stale = map.size !== marks.size
|
||||
parts = next
|
||||
marks = map
|
||||
const retained = next.filter((part): part is Mention => part !== undefined)
|
||||
const stale = tracked !== marks.size || retained.length !== parts.length
|
||||
parts = retained
|
||||
if (stale) {
|
||||
restoreParts(next)
|
||||
restoreParts(retained)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +712,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
parts = []
|
||||
marks = new Map()
|
||||
setDraftParts([])
|
||||
}
|
||||
|
||||
const restoreParts = (value: RunPromptPart[]) => {
|
||||
@@ -581,6 +720,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
parts = value
|
||||
.filter((item): item is Mention => item.type === "file" || item.type === "agent" || item.type === "skill")
|
||||
.map((item) => structuredClone(item))
|
||||
setDraftParts(parts)
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
@@ -604,6 +744,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
revision += 1
|
||||
draft = promptCopy(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
@@ -619,6 +760,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const resetDraft = () => {
|
||||
revision += 1
|
||||
if (area && !area.isDestroyed) {
|
||||
area.setText("")
|
||||
}
|
||||
@@ -719,6 +861,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
syncParts()
|
||||
setDraftParts(parts)
|
||||
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
|
||||
draft = shell()
|
||||
? {
|
||||
@@ -733,6 +876,87 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
const pasteAttachment = (file: { uri: string; filename?: string }) => {
|
||||
if (!area || area.isDestroyed) return
|
||||
syncDraft()
|
||||
const value = promptAttachmentLabel(attachments(), { uri: file.uri, name: file.filename })
|
||||
area.insertText(value + " ")
|
||||
const end = area.cursorOffset - 1
|
||||
const start = end - stringWidth(value)
|
||||
const id = area.extmarks.create({ start, end, virtual: true, typeId: type })
|
||||
marks.set(id, parts.length)
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: file.uri,
|
||||
filename: file.filename,
|
||||
mime: file.uri.slice(5, file.uri.indexOf(";")),
|
||||
source: { type: "file", text: { start, end, value } },
|
||||
})
|
||||
syncDraft()
|
||||
}
|
||||
|
||||
const paste = (text?: string) => {
|
||||
const next = (pasteQueue ?? Promise.resolve())
|
||||
.then(async () => {
|
||||
const target = area
|
||||
if (disposed || !target || target.isDestroyed || !input.prompt()) return
|
||||
const before = revision
|
||||
const changed = () =>
|
||||
disposed || area !== target || target.isDestroyed || revision !== before || !input.prompt()
|
||||
const content =
|
||||
text === undefined
|
||||
? await (input.clipboard ?? (clipboard ??= createTuiClipboard(renderer))).read()
|
||||
: { mime: "text/plain", data: text }
|
||||
if (!content || changed()) return
|
||||
const image = content.mime.startsWith("image/")
|
||||
if (image && shell()) {
|
||||
input.onStatus("image attachments are unavailable in shell mode")
|
||||
return
|
||||
}
|
||||
if (!image && content.mime !== "text/plain") return
|
||||
const normalized = image ? content.data : stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
|
||||
const files = image
|
||||
? [{ type: "file" as const, uri: `data:${content.mime};base64,${content.data}`, filename: "clipboard" }]
|
||||
: shell()
|
||||
? undefined
|
||||
: await resolvePastedAttachments(normalized, process.platform)
|
||||
if (changed()) return
|
||||
// A paste's own text edits must not cancel a submit waiting on that paste.
|
||||
applyingPaste = true
|
||||
try {
|
||||
files?.forEach((file) => {
|
||||
if (file.type === "file") {
|
||||
pasteAttachment(file)
|
||||
return
|
||||
}
|
||||
target.insertText(file.content)
|
||||
})
|
||||
if (!files) target.insertText(normalized)
|
||||
} finally {
|
||||
applyingPaste = false
|
||||
}
|
||||
hide()
|
||||
syncDraft()
|
||||
target.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
scheduleRows()
|
||||
})
|
||||
.catch((error) => {
|
||||
revision += 1
|
||||
if (!disposed) input.onStatus(errorMessage(error))
|
||||
})
|
||||
.finally(() => {
|
||||
if (pasteQueue === next) pasteQueue = undefined
|
||||
})
|
||||
pasteQueue = next
|
||||
return next
|
||||
}
|
||||
|
||||
const onPaste = (event: PasteEvent) => {
|
||||
event.preventDefault()
|
||||
return paste(event.bytes.length ? decodePasteBytes(event.bytes) : undefined)
|
||||
}
|
||||
|
||||
const push = (value: RunPrompt) => {
|
||||
history = pushPromptHistory(history, value)
|
||||
}
|
||||
@@ -788,7 +1012,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
const requestExit = () => {
|
||||
const text = area && !area.isDestroyed ? area.plainText : draft.text
|
||||
if (input.prompt() && text.length > 0) {
|
||||
revision += 1
|
||||
if (input.prompt() && (text.length > 0 || draft.parts.some((part) => part.type === "file"))) {
|
||||
input.onInputClear()
|
||||
resetDraft()
|
||||
return true
|
||||
@@ -1033,6 +1258,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: input.prompt(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.paste",
|
||||
title: "Paste",
|
||||
group: "Prompt",
|
||||
run: () => paste(),
|
||||
},
|
||||
{
|
||||
id: "session.interrupt",
|
||||
title: "Interrupt session",
|
||||
@@ -1055,8 +1286,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
group: "Prompt",
|
||||
palette: true,
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
onSubmit("queue")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1210,7 +1440,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
if (submitting) return
|
||||
|
||||
if (!next.text.trim()) {
|
||||
if (!next.text.trim() && !next.parts.some((part) => part.type === "file")) {
|
||||
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||
if (queued) {
|
||||
submitting = true
|
||||
@@ -1282,9 +1512,17 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = (delivery: RunDelivery = "steer") => {
|
||||
if (pasteQueue) {
|
||||
const before = revision
|
||||
void pasteQueue.then(() => {
|
||||
if (revision === before) onSubmit(delivery)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (disposed || !input.prompt()) return
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft))
|
||||
submitPrompt(promptCopy(draft), delivery)
|
||||
}
|
||||
|
||||
const submitText = (text: string) => {
|
||||
@@ -1292,14 +1530,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
void clipboard?.dispose().catch(() => {})
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setStatusRows(input.statusRows())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.width()
|
||||
popup()
|
||||
layout()
|
||||
if (input.prompt()) {
|
||||
scheduleRows()
|
||||
}
|
||||
@@ -1354,17 +1598,22 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
selected: menu.selected,
|
||||
offset: menu.offset,
|
||||
rows: menu.rows,
|
||||
images,
|
||||
layout,
|
||||
requestExit,
|
||||
onSubmit,
|
||||
onSubmit: () => onSubmit(),
|
||||
submitText,
|
||||
openEditor,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onContentChange: () => {
|
||||
if (!applyingPaste && area && area.plainText !== draft.text) revision += 1
|
||||
input.onInputClear()
|
||||
syncDraft()
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
onSizeChange: scheduleRows,
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "../component/register-spinner"
|
||||
import { Show, createMemo, indexArray } from "solid-js"
|
||||
import { Show, createMemo, createSignal, indexArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../component/spinner-frames"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { footerMenuText } from "./footer.menu"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -14,7 +16,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 14
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.highlight
|
||||
return theme.success
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
@@ -25,7 +27,7 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
|
||||
return theme.error
|
||||
}
|
||||
|
||||
return theme.highlight
|
||||
return theme.running
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
|
||||
@@ -59,6 +61,10 @@ export function RunFooterSubagentBody(props: {
|
||||
shellOutput?: () => boolean
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [size, setSize] = createSignal(dims())
|
||||
const width = () => size().width
|
||||
const compact = () => width() < 56 || size().height < 12
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const tab = createMemo(() => props.tab())
|
||||
@@ -102,6 +108,11 @@ export function RunFooterSubagentBody(props: {
|
||||
if (tab()?.status !== "running") return undefined
|
||||
return props.interrupt?.()
|
||||
})
|
||||
const count = () => (props.total() > 1 && props.index() > 0 ? `${props.index()} of ${props.total()}` : "")
|
||||
const headerControlsWidth = () =>
|
||||
(interruptHint() ? stringWidth(`${interruptHint()} interrupt`) + 1 : 0) + (count() ? stringWidth(count()) + 1 : 0)
|
||||
const headerControls = () => !compact() && stringWidth(title()) + 2 + headerControlsWidth() <= width() - 4
|
||||
const titleWidth = () => Math.max(1, width() - (compact() ? 2 : 6) - (headerControls() ? headerControlsWidth() : 0))
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
@@ -129,70 +140,130 @@ export function RunFooterSubagentBody(props: {
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "pageup" || event.name === "pagedown") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={footer().surface}>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<Show when={tab()}>
|
||||
{(current) => (
|
||||
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner
|
||||
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
|
||||
interval={props.mono ? 160 : 80}
|
||||
color={statusColor(footer(), current().status)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status, props.mono ?? false)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{title()}
|
||||
<Show when={subtitle().length > 0}>
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
minHeight={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={footer().surface}
|
||||
paddingTop={compact() ? 0 : 1}
|
||||
paddingLeft={compact() ? 0 : 1}
|
||||
paddingRight={compact() ? 0 : 3}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
onSizeChange={function () {
|
||||
setSize({ width: this.width, height: this.height })
|
||||
}}
|
||||
>
|
||||
<Show when={tab()}>
|
||||
{(current) => (
|
||||
<box
|
||||
width="100%"
|
||||
height={compact() ? 1 : 2}
|
||||
paddingBottom={compact() ? 0 : 1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner
|
||||
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
|
||||
interval={props.mono ? 160 : 80}
|
||||
color={statusColor(footer(), current().status)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status, props.mono ?? false)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" flexGrow={1} flexShrink={1}>
|
||||
{footerMenuText(title(), titleWidth(), props.mono)}
|
||||
<Show when={subtitle().length > 0 && titleWidth() >= stringWidth(title()) + stringWidth(subtitle()) + 2}>
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={headerControls()}>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
{hint()} interrupt
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.index()} of {props.total()}
|
||||
</text>
|
||||
<Show when={count()}>
|
||||
{(value) => (
|
||||
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
viewportOptions={{ paddingRight: props.mono ? 0 : 1 }}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0} flexShrink={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text width="100%" fg={footer().muted} wrapMode="word" flexShrink={0}>
|
||||
No subagent activity yet
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text fg={footer().muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={!headerControls()}>
|
||||
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
|
||||
<text height={1} fg={footer().actionSecondaryText} wrapMode="none" flexShrink={0} onMouseUp={props.onClose}>
|
||||
esc back
|
||||
</text>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text maxWidth="100%" fg={footer().actionSecondaryText} wrapMode="word" flexShrink={0}>
|
||||
{hint()} {width() >= stringWidth(hint()) + 10 ? "interrupt" : "stop"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={width() >= 56}>
|
||||
<text height={1} fg={footer().muted} wrapMode="none" flexShrink={0}>
|
||||
pgup/pgdn scroll
|
||||
</text>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text
|
||||
height={1}
|
||||
fg={footer().actionSecondaryText}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
onMouseUp={() => props.onCycle(1)}
|
||||
>
|
||||
tab next {props.index()}/{props.total()}
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
+136
-48
@@ -24,19 +24,22 @@
|
||||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
||||
// two-press pattern where the first press shows a hint and the second press
|
||||
// within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { CliRenderEvents, type CliRenderer, type CliRendererExternalOutputEvent } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { batch, createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||
import { RUN_SUBAGENT_PANEL_ROWS, footerPanelLayout } from "./footer.command"
|
||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { TEXTAREA_MIN_ROWS, footerPromptLayout } from "./footer.prompt"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { monoSnapshot } from "./mono"
|
||||
import { RunScrollbackStream } from "./scrollback.surface"
|
||||
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
|
||||
import { resolveRunTheme, type RunTheme } from "./theme"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { entrySplash } from "./splash"
|
||||
import { SEED_LAUNCH } from "../ui/one-cell-motion"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
@@ -75,6 +78,7 @@ type RunFooterOptions = {
|
||||
agents: RunAgent[]
|
||||
references: RunReference[]
|
||||
wrote?: boolean
|
||||
startup?: { version: string; detail: string }
|
||||
agent: string | undefined
|
||||
modelLabel: string
|
||||
model: RunInput["model"]
|
||||
@@ -82,12 +86,12 @@ type RunFooterOptions = {
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
mono: boolean
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: {
|
||||
current: MiniSettings
|
||||
update?: (change: MiniSettingChange) => Promise<MiniSettings>
|
||||
}
|
||||
onMonoChange?: (mono: boolean) => void
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
@@ -208,6 +212,9 @@ export class RunFooter implements FooterApi {
|
||||
private noticeTimeout: NodeJS.Timeout | undefined
|
||||
private turnAgent: string | undefined
|
||||
private requestExitHandler: (() => boolean) | undefined
|
||||
private startup: Accessor<RunFooterOptions["startup"]>
|
||||
private setStartup: Setter<RunFooterOptions["startup"]>
|
||||
private startupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private scrollback: RunScrollbackStream
|
||||
private themes: RunTheme[]
|
||||
private paletteRefreshRunning = false
|
||||
@@ -225,7 +232,8 @@ export class RunFooter implements FooterApi {
|
||||
.finally(() => this.destroyTheme(theme))
|
||||
},
|
||||
shellOutput: () => this.miniSettings().shell_output === "show",
|
||||
mono: this.options.mono,
|
||||
mono: this.miniSettings().mono,
|
||||
imagePreview: this.options.tuiConfig.session?.image_preview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,7 +246,7 @@ export class RunFooter implements FooterApi {
|
||||
status: "",
|
||||
notice: "",
|
||||
model: options.modelLabel,
|
||||
usage: "",
|
||||
usage: undefined,
|
||||
first: options.first,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
@@ -300,16 +308,19 @@ export class RunFooter implements FooterApi {
|
||||
const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current)
|
||||
this.miniSettings = miniSettings
|
||||
this.setMiniSettings = setMiniSettings
|
||||
const [startup, setStartup] = createSignal(options.startup)
|
||||
this.startup = startup
|
||||
this.setStartup = setStartup
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
if (!options.mono) {
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
}
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
|
||||
const footer = this
|
||||
void render(
|
||||
@@ -320,6 +331,7 @@ export class RunFooter implements FooterApi {
|
||||
return createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: footer.state,
|
||||
startup: footer.startup,
|
||||
view: footer.view,
|
||||
subagent: footer.subagent,
|
||||
queuedPrompts: footer.queuedPrompts,
|
||||
@@ -330,13 +342,14 @@ export class RunFooter implements FooterApi {
|
||||
providers: footer.providers,
|
||||
currentAgent: footer.currentAgent,
|
||||
currentAgentID: footer.currentAgentID,
|
||||
currentAgentExplicit: () => selectedAgentID() !== undefined,
|
||||
currentModel: footer.currentModel,
|
||||
variants: footer.variants,
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: footer.theme,
|
||||
tuiConfig: options.tuiConfig,
|
||||
mono: options.mono,
|
||||
get mono() {
|
||||
return footer.miniSettings().mono
|
||||
},
|
||||
miniSettings: footer.miniSettings,
|
||||
history: footer.history,
|
||||
onSubmit: footer.handlePrompt,
|
||||
@@ -347,7 +360,10 @@ export class RunFooter implements FooterApi {
|
||||
onInterrupt: footer.handleInterrupt,
|
||||
onBackground: options.onBackground,
|
||||
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||
onEditorOpen: options.onEditorOpen,
|
||||
onEditorOpen: (input) => {
|
||||
footer.finishStartup()
|
||||
return options.onEditorOpen(input)
|
||||
},
|
||||
onInputClear: footer.handleInputClear,
|
||||
onExitRequest: footer.handleExit,
|
||||
onRequestExit: footer.setRequestExitHandler,
|
||||
@@ -370,6 +386,26 @@ export class RunFooter implements FooterApi {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
if (options.startup)
|
||||
this.startupTimer = setTimeout(() => this.finishStartup(), (SEED_LAUNCH.frames.length - 1) * SEED_LAUNCH.interval)
|
||||
}
|
||||
|
||||
public finishStartup(): void {
|
||||
const startup = this.startup()
|
||||
if (!startup) return
|
||||
clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
this.setStartup(undefined)
|
||||
if (this.isGone) return
|
||||
this.applyHeight()
|
||||
this.renderer.writeToScrollback(
|
||||
entrySplash({
|
||||
...startup,
|
||||
theme: this.theme().splash,
|
||||
mono: this.miniSettings().mono,
|
||||
}),
|
||||
)
|
||||
this.renderer.requestRender()
|
||||
}
|
||||
|
||||
public get isClosed(): boolean {
|
||||
@@ -415,6 +451,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
this.finishStartup()
|
||||
const agent = this.turnAgent ?? this.currentAgent()
|
||||
this.turnAgent = undefined
|
||||
if (this.miniSettings().turn_summary === "hide") return
|
||||
@@ -516,7 +553,7 @@ export class RunFooter implements FooterApi {
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
notice: typeof next.notice === "string" ? next.notice : prev.notice,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
usage: "usage" in next ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
interrupt:
|
||||
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
|
||||
@@ -551,6 +588,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if (view.type !== "prompt") this.finishStartup()
|
||||
this.setView(view)
|
||||
this.applyHeight()
|
||||
}
|
||||
@@ -564,6 +602,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
const last = this.queue.at(-1)
|
||||
const merged = last ? coalesceProgressCommit(last, commit) : undefined
|
||||
if (merged) this.queue[this.queue.length - 1] = merged
|
||||
@@ -614,6 +653,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
this.scrollback.destroy()
|
||||
this.scrollback = this.createScrollback(wrote)
|
||||
}
|
||||
@@ -641,6 +681,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
this.finishStartup()
|
||||
this.flush()
|
||||
this.notifyClose()
|
||||
}
|
||||
@@ -693,23 +734,34 @@ export class RunFooter implements FooterApi {
|
||||
this.patch({ interrupt: 0, exit: 0 })
|
||||
}
|
||||
|
||||
// Resizes the footer to fit the current view. Permission and form views
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private handleResize = (): void => {
|
||||
if (!this.isGone) this.applyHeight()
|
||||
}
|
||||
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const route = this.promptRoute.type
|
||||
const height =
|
||||
const panel = footerPanelLayout(this.renderer.terminalHeight)
|
||||
const prompt = footerPromptLayout(this.renderer.terminalHeight)
|
||||
const desired =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "form"
|
||||
? this.base + FORM_ROWS
|
||||
: ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
|
||||
? 1 + RUN_COMMAND_PANEL_ROWS
|
||||
? 1 + panel.frame + panel.limit
|
||||
: route === "queued-menu" || route === "subagent-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: route === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
|
||||
: prompt.padding * 2 + 1 + this.rows
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
desired + (this.startup() ? 2 : 0),
|
||||
this.renderer.terminalHeight - (type === "prompt" && route === "composer" ? 1 : 0),
|
||||
),
|
||||
)
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
@@ -721,7 +773,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, value)
|
||||
if (rows === this.rows) {
|
||||
return
|
||||
}
|
||||
@@ -733,6 +785,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number }): void => {
|
||||
if (next.route.type !== "composer") this.finishStartup()
|
||||
this.promptRoute = next.route
|
||||
this.subagentMenuRows = next.subagentRows
|
||||
if (this.view().type === "prompt") {
|
||||
@@ -861,8 +914,29 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
try {
|
||||
this.setMiniSettings(await this.options.miniSettings.update(change))
|
||||
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
|
||||
const settings = await this.options.miniSettings.update(change)
|
||||
if (this.isClosed) return
|
||||
if (settings.mono === this.miniSettings().mono) {
|
||||
this.setMiniSettings(settings)
|
||||
this.setNotice("settings updated")
|
||||
return
|
||||
}
|
||||
const theme = await resolveRunTheme(this.renderer, this.options.tuiConfig.theme, settings.mono)
|
||||
this.flush()
|
||||
this.flushing = this.flushing.then(async () => {
|
||||
if (this.isClosed) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
await this.scrollback.setMono(settings.mono)
|
||||
batch(() => {
|
||||
this.setMiniSettings(settings)
|
||||
this.applyTheme(theme)
|
||||
this.options.onMonoChange?.(settings.mono)
|
||||
})
|
||||
})
|
||||
await this.flushing
|
||||
this.setNotice("settings updated")
|
||||
} catch (error) {
|
||||
this.setNotice("failed to save settings")
|
||||
throw error
|
||||
@@ -959,26 +1033,34 @@ export class RunFooter implements FooterApi {
|
||||
return true
|
||||
}
|
||||
|
||||
private handlePalette = (): void => {
|
||||
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
private applyTheme(theme: RunTheme): void {
|
||||
if (theme === this.theme()) return
|
||||
this.themes.push(theme)
|
||||
this.setTheme(theme)
|
||||
this.renderer.setBackgroundColor(theme.background)
|
||||
this.scrollback.setTheme(theme)
|
||||
}
|
||||
|
||||
// Keep the last known good theme when a runtime OSC probe times out.
|
||||
if (theme === RUN_THEME_FALLBACK) {
|
||||
return
|
||||
}
|
||||
private handleExternalOutput = (event: CliRendererExternalOutputEvent): void => {
|
||||
if (this.miniSettings().mono) monoSnapshot(event)
|
||||
}
|
||||
|
||||
this.themes.push(theme)
|
||||
this.setTheme(theme)
|
||||
this.renderer.setBackgroundColor(theme.background)
|
||||
private handlePalette = (): Promise<void> | undefined => {
|
||||
if (this.isGone || this.paletteRefreshRunning) return
|
||||
const mono = this.miniSettings().mono
|
||||
return resolveRunTheme(this.renderer, this.options.tuiConfig.theme, mono).then((theme) => {
|
||||
this.flushing = this.flushing
|
||||
.then(() => this.scrollback.setTheme(theme))
|
||||
.then(() => {
|
||||
if (this.isGone || mono !== this.miniSettings().mono) {
|
||||
theme.block.syntax?.destroy()
|
||||
return
|
||||
}
|
||||
this.applyTheme(theme)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.flushError = error
|
||||
})
|
||||
return this.flushing
|
||||
})
|
||||
}
|
||||
|
||||
@@ -993,10 +1075,11 @@ export class RunFooter implements FooterApi {
|
||||
return false
|
||||
}
|
||||
|
||||
private handleThemeRefresh = (): void => {
|
||||
if (this.isGone || this.options.mono) {
|
||||
private handleThemeRefresh = (): Promise<void> | undefined => {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
if (this.miniSettings().mono) return this.handlePalette()
|
||||
|
||||
if (this.paletteRefreshRunning) {
|
||||
this.paletteRefreshQueued = true
|
||||
@@ -1006,22 +1089,23 @@ export class RunFooter implements FooterApi {
|
||||
this.paletteRefreshRunning = true
|
||||
const retry = this.renderer.paletteDetectionStatus === "detecting"
|
||||
this.renderer.clearPaletteCache()
|
||||
void this.renderer
|
||||
return this.renderer
|
||||
.getPalette({ size: 256 })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
.then(() => {
|
||||
this.paletteRefreshRunning = false
|
||||
if (!retry && !this.paletteRefreshQueued) {
|
||||
return
|
||||
// Theme files can change without a new terminal palette.
|
||||
return this.handlePalette()
|
||||
}
|
||||
|
||||
this.paletteRefreshQueued = false
|
||||
this.handleThemeRefresh()
|
||||
return this.handleThemeRefresh()
|
||||
})
|
||||
}
|
||||
|
||||
public refreshTheme(): void {
|
||||
this.handleThemeRefresh()
|
||||
public refreshTheme() {
|
||||
return this.handleThemeRefresh()
|
||||
}
|
||||
|
||||
private handleThemeSignal = (): void => {
|
||||
@@ -1039,6 +1123,8 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
this.flush()
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
@@ -1046,6 +1132,8 @@ export class RunFooter implements FooterApi {
|
||||
this.clearExitTimer()
|
||||
this.clearNoticeTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.renderer.off(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
|
||||
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.removeInputHandler(this.handleThemeNotification)
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextBuffer, TextBufferView } from "@opentui/core"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { registerOpencodeSpinner } from "../component/register-spinner"
|
||||
import { createColors, createFrames } from "../ui/spinner"
|
||||
import { OneCellSpinner } from "../component/one-cell-spinner"
|
||||
import { WORK_SPINNERS, SEED_LAUNCH, SEED_MONO } from "../ui/one-cell-motion"
|
||||
import { entrySplashLayout } from "./splash"
|
||||
import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunAgentSelectBody,
|
||||
@@ -31,9 +33,11 @@ import { RunFormBody } from "./footer.form"
|
||||
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
import { footerStatuslinePolicy } from "./footer.width"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { formatContextUsage } from "../util/session"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSingleFlight } from "../util/single-flight"
|
||||
|
||||
@@ -59,7 +63,7 @@ import type {
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
@@ -84,11 +88,11 @@ type RunFooterViewProps = {
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentAgent: () => string
|
||||
currentAgentID: () => string | undefined
|
||||
currentAgentExplicit: () => boolean
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
state: () => FooterState
|
||||
startup?: () => { version: string; detail: string } | undefined
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
@@ -97,6 +101,7 @@ type RunFooterViewProps = {
|
||||
mono: boolean
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
clipboard?: Pick<ClipboardService, "read">
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
@@ -122,8 +127,13 @@ type RunFooterViewProps = {
|
||||
}
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const renderer = useRenderer()
|
||||
const term = useTerminalDimensions()
|
||||
const width = createMemo(() => term().width)
|
||||
const startup = createMemo(() => {
|
||||
const value = props.startup?.()
|
||||
return value ? entrySplashLayout({ ...value, width: width(), mono: props.mono }) : undefined
|
||||
})
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
@@ -181,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
|
||||
const model = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
return current ? modelInfo(props.providers(), current).model : undefined
|
||||
return current ? modelInfo(props.providers(), current) : undefined
|
||||
})
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
@@ -198,10 +208,18 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
|
||||
const clearShortcut = () => shortcut("prompt.clear")
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const started = createMemo(() => (busy() ? performance.now() : undefined))
|
||||
const statusWidth = createMemo(() => Math.max(1, width() - (busy() ? 2 : 0)))
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const footerDetails = createMemo(() => props.miniSettings().footer === "show")
|
||||
const contextUsage = createMemo(() => {
|
||||
const current = usage()
|
||||
return current && current.tokens > 0 ? formatContextUsage(current.tokens, current.percent) : ""
|
||||
})
|
||||
const cost = createMemo(() => (usage()?.cost ? money.format(usage()!.cost!) : ""))
|
||||
const takeover = createMemo(() => exiting() || (busy() && armed()) || !!props.state().notice.trim())
|
||||
const footerDetails = createMemo(() => props.miniSettings().footer === "show" && !takeover())
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
return
|
||||
@@ -211,31 +229,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
const runTheme = createMemo(() => props.theme())
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
if (props.mono) {
|
||||
return {
|
||||
frames: ["-", "\\", "|", "/"],
|
||||
color: theme().text,
|
||||
}
|
||||
}
|
||||
const options = {
|
||||
color: theme().highlight,
|
||||
style: "blocks" as const,
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}
|
||||
return {
|
||||
frames: createFrames(options),
|
||||
color: createColors(options),
|
||||
}
|
||||
const agentColor = createMemo(() => {
|
||||
const colors = theme().categorical
|
||||
const index = props
|
||||
.agents()
|
||||
.filter((agent) => !agent.hidden)
|
||||
.findIndex((agent) => agent.id === props.currentAgentID())
|
||||
return colors[Math.max(0, index) % colors.length]!
|
||||
})
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const footerStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
const current = model()?.model ?? props.state().model.trim()
|
||||
const variant = props.currentVariant()
|
||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||
if (contextUsage()) details.push(contextUsage())
|
||||
if (cost()) details.push(cost())
|
||||
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
@@ -363,6 +372,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
openTab(next.sessionID)
|
||||
}
|
||||
const [promptRows, setPromptRows] = createSignal(1)
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
@@ -373,8 +383,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
view: promptView,
|
||||
prompt,
|
||||
width,
|
||||
statusRows: () => (menu() ? 0 : statusRows()),
|
||||
theme,
|
||||
mono: () => props.mono,
|
||||
imagePreview: props.tuiConfig.prompt?.image_preview,
|
||||
clipboard: props.clipboard,
|
||||
history: props.history,
|
||||
queuedPrompts: queue,
|
||||
onQueuedPromptSteer: (inboxID) => queuedPromptAction("steer", inboxID),
|
||||
@@ -387,63 +400,44 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onExit: props.onExit,
|
||||
onSkillMenu: openSkillMenu,
|
||||
onSettings: openSettings,
|
||||
onRows: props.onRows,
|
||||
onRows: setPromptRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const shell = createMemo(() => prompt() && composer.shell())
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
const stateStatus = createMemo(() => props.state().status.trim())
|
||||
const notice = createMemo(() => props.state().notice.trim())
|
||||
const modeLabel = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return "EXIT"
|
||||
}
|
||||
|
||||
return shell() ? "SHELL" : undefined
|
||||
})
|
||||
const modeColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return theme().error
|
||||
}
|
||||
|
||||
if (shell()) {
|
||||
return theme().warning
|
||||
}
|
||||
|
||||
return theme().highlight
|
||||
})
|
||||
const statusText = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
|
||||
if (exiting() || (busy() && armed())) {
|
||||
const key = exiting() ? clearShortcut() : interruptLabel()
|
||||
const action = exiting() ? "exit" : "stop"
|
||||
if (!key) return exiting() ? "Exit pending" : "Stop pending"
|
||||
const phrases = [
|
||||
`Press ${key} again to ${exiting() ? "exit" : "interrupt"}`,
|
||||
`${key} again to ${exiting() ? "exit" : "interrupt"}`,
|
||||
`${key} again: ${action}`,
|
||||
`${key} ${action}`,
|
||||
]
|
||||
return phrases.find((text) => stringWidth(text) <= statusWidth()) ?? phrases[phrases.length - 1]!
|
||||
}
|
||||
|
||||
if (busy() && armed()) return "again to interrupt"
|
||||
|
||||
if (notice()) return notice()
|
||||
|
||||
if (!footerDetails()) return shell() ? "Shell mode" : ""
|
||||
|
||||
if (busy()) return "interrupt"
|
||||
|
||||
if (stateStatus().length > 0) {
|
||||
return stateStatus()
|
||||
if (!footerDetails()) return shell() ? "Shell" : ""
|
||||
if (busy()) {
|
||||
return interruptLabel() ? `${interruptLabel()} stop` : "Running"
|
||||
}
|
||||
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!footerDetails()) return ""
|
||||
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
|
||||
return stateStatus() || (shell() ? "Shell" : "")
|
||||
})
|
||||
const agentStatus = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
|
||||
if (!footerDetails() || !prompt() || shell()) return undefined
|
||||
return props.currentAgent()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
const current = model()?.model ?? props.state().model.trim()
|
||||
if (!footerDetails() || !prompt() || shell() || !current) return
|
||||
return {
|
||||
model: current,
|
||||
provider: model()?.provider,
|
||||
variant: props.currentVariant(),
|
||||
}
|
||||
})
|
||||
@@ -453,7 +447,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
if (armed()) {
|
||||
return theme().highlight
|
||||
return theme().warning
|
||||
}
|
||||
|
||||
if (busy() || notice().length > 0 || stateStatus().length > 0) {
|
||||
@@ -462,79 +456,87 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
return theme().muted
|
||||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const contextHintCandidates = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: Array<{ key: string; label: string }> = []
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
const items: Array<{ id: "queued" | "subagents" | "background"; key: string; label: string; expanded?: string }> =
|
||||
[]
|
||||
if (queue().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
items.push({ id: "queued", key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
items.push({
|
||||
id: "subagents",
|
||||
key: subagentShortcut(),
|
||||
label: `${activeTabs().length} sub`,
|
||||
expanded: `${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`,
|
||||
})
|
||||
}
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ id: "background", key: backgroundShortcut(), label: "bg", expanded: "background" })
|
||||
}
|
||||
return items
|
||||
})
|
||||
const commandHint = createMemo(() => {
|
||||
if (!prompt()) return
|
||||
|
||||
if (shell()) {
|
||||
return { key: "esc", label: "normal" }
|
||||
}
|
||||
|
||||
if (!prompt() || takeover() || shell()) return
|
||||
if (command()) {
|
||||
return { key: command(), label: "cmd" }
|
||||
return { key: command(), label: "menu" }
|
||||
}
|
||||
})
|
||||
const commandHintWidth = createMemo(() => {
|
||||
const hint = commandHint()
|
||||
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
|
||||
})
|
||||
const statuslineText = createMemo(() =>
|
||||
busy() && !exiting() && (footerDetails() || armed())
|
||||
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
|
||||
: statusText(),
|
||||
)
|
||||
const statuslineMainWidth = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
|
||||
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
|
||||
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
|
||||
})
|
||||
const visibleModeLabel = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
|
||||
return mode
|
||||
})
|
||||
const statuslineMainAvailable = createMemo(() => {
|
||||
const mode = visibleModeLabel()
|
||||
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
|
||||
})
|
||||
const statuslineLayout = createMemo(() => {
|
||||
const agent = agentStatus()
|
||||
const info = modelStatus()
|
||||
return footerStatuslinePolicy({
|
||||
width: width(),
|
||||
mainWidth: statuslineMainWidth(),
|
||||
commandWidth: commandHint() ? commandHintWidth() : undefined,
|
||||
agentWidth: agent ? stringWidth(agent) : undefined,
|
||||
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
|
||||
modelWidth: info ? stringWidth(info.model) : undefined,
|
||||
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
|
||||
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
|
||||
mono: props.mono,
|
||||
status: {
|
||||
text: statusText(),
|
||||
expanded: footerDetails() && busy() && interruptLabel() ? `${interruptLabel()} interrupt` : undefined,
|
||||
},
|
||||
escape: shell() && !takeover() ? { key: "esc", label: "normal" } : undefined,
|
||||
work: contextHintCandidates(),
|
||||
model: info ? { name: info.model, variant: info.variant } : undefined,
|
||||
agent: agentStatus(),
|
||||
context:
|
||||
footerDetails() && contextUsage()
|
||||
? {
|
||||
compact: usage()?.percent === undefined ? contextUsage() : `${usage()!.percent}% ctx`,
|
||||
full: contextUsage(),
|
||||
}
|
||||
: undefined,
|
||||
cost: footerDetails() ? cost() : undefined,
|
||||
provider: info?.provider,
|
||||
menu: commandHint(),
|
||||
spinner: busy() ? (props.mono ? "*" : "\u25aa") : undefined,
|
||||
})
|
||||
})
|
||||
const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
|
||||
const hasStatuslineInfo = createMemo(() => {
|
||||
const layout = statuslineLayout()
|
||||
return layout.showUsage || layout.showAgent || layout.showModel
|
||||
const statusSections = createMemo(() => statuslineLayout().groups.filter((group) => group.id !== "spinner"))
|
||||
const statusColors = createMemo(() => ({
|
||||
text: theme().text,
|
||||
muted: theme().muted,
|
||||
agent: agentColor(),
|
||||
status: statusColor(),
|
||||
}))
|
||||
const statusRows = createMemo(() => {
|
||||
const text = statusSections()
|
||||
.map((group) => group.parts.map((part) => part.text).join(""))
|
||||
.join(props.mono ? " - " : " \u00b7 ")
|
||||
if (stringWidth(text) <= statusWidth() && !text.includes("\n")) return 1
|
||||
// Measure outside the clipped footer so wrapped required controls can grow it.
|
||||
const buffer = TextBuffer.create(renderer.widthMethod)
|
||||
const view = TextBufferView.create(buffer)
|
||||
buffer.setText(text)
|
||||
view.setWrapMode("word")
|
||||
view.setWrapWidth(statusWidth())
|
||||
const rows = Math.max(1, view.getVirtualLineCount())
|
||||
view.destroy()
|
||||
buffer.destroy()
|
||||
return rows
|
||||
})
|
||||
createEffect(() => {
|
||||
props.onRows(promptRows() + (!panel() && !menu() && !inspecting() ? statusRows() : 0) - 1)
|
||||
})
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
|
||||
|
||||
createEffect(() => {
|
||||
props.onRequestExit?.(composer.requestExit)
|
||||
@@ -692,6 +694,23 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<Show when={startup()}>
|
||||
{(layout) => (
|
||||
<box id="mini-startup" height={2} flexShrink={0} paddingTop={1} flexDirection="row">
|
||||
<Show when={layout().label.startsWith("\u25aa")}>
|
||||
<OneCellSpinner
|
||||
animation={SEED_LAUNCH}
|
||||
color={runTheme().splash.right}
|
||||
animations={props.tuiConfig.animations}
|
||||
/>
|
||||
</Show>
|
||||
<text fg={runTheme().splash.right} wrapMode="none">
|
||||
{layout().label.startsWith("\u25aa") ? layout().label.slice(1) : layout().label}
|
||||
<span style={{ fg: runTheme().splash.left }}>{layout().metadata}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={panel() || inspecting()}>
|
||||
<box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
</Show>
|
||||
@@ -706,7 +725,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={panel() || prompt() ? false : ["left"]}
|
||||
borderColor={panel() || prompt() ? undefined : theme().highlight}
|
||||
borderColor={panel() || prompt() ? undefined : theme().border}
|
||||
customBorderChars={
|
||||
panel() || prompt()
|
||||
? undefined
|
||||
@@ -733,10 +752,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme}
|
||||
cursorStyle={props.tuiConfig.cursor}
|
||||
background={() => runTheme().background}
|
||||
rail={() => (shell() ? theme().formfieldFocusedText : agentColor())}
|
||||
mono={props.mono}
|
||||
placeholder={composer.placeholder}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onPaste={composer.onPaste}
|
||||
images={composer.images}
|
||||
layout={composer.layout}
|
||||
onContentChange={composer.onContentChange}
|
||||
onSizeChange={composer.onSizeChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
@@ -874,6 +899,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onClose={closePanel}
|
||||
onChange={props.onMiniSettingChange}
|
||||
mono={props.mono}
|
||||
animations={props.tuiConfig.animations}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
@@ -928,128 +954,48 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
rows={composer.rows}
|
||||
limit={FOOTER_MENU_ROWS}
|
||||
border={false}
|
||||
paddingLeft={0}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!panel() && !menu()}>
|
||||
<box
|
||||
id="mini-statusline"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
gap={0}
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<Show when={visibleModeLabel()}>
|
||||
{(label) => (
|
||||
<box
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().statusAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<Show when={statuslineLayout().groups.some((group) => group.id === "spinner")}>
|
||||
<box id="mini-work-spinner" width={1} flexShrink={0}>
|
||||
<OneCellSpinner
|
||||
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.miniSettings().work_spinner]}
|
||||
color={agentColor()}
|
||||
animations={props.tuiConfig.animations}
|
||||
glow={!props.mono}
|
||||
still={props.mono ? "*" : undefined}
|
||||
age={performance.now() - (started() ?? performance.now())}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
|
||||
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
|
||||
backgroundColor="transparent"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
footerDetails() &&
|
||||
busy() &&
|
||||
!exiting() &&
|
||||
statuslineMainAvailable() >=
|
||||
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
|
||||
}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
|
||||
<Show when={interruptLabel()}>
|
||||
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
|
||||
</Show>
|
||||
{statusText()}
|
||||
</Show>
|
||||
<Show when={statusSections().length > 0}>
|
||||
<text fg={statusColor()} wrapMode="word" width={statusWidth()} flexShrink={0} height={statusRows()}>
|
||||
<For each={statusSections()}>
|
||||
{(section, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
|
||||
</Show>
|
||||
<For each={section.parts}>
|
||||
{(part) => <span style={{ fg: statusColors()[part.tone] }}>{part.text}</span>}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={statuslineLayout().showUsage && activityMeta()}>
|
||||
{(usage) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().muted} wrapMode="none">
|
||||
{usage()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showAgent && agentStatus()}>
|
||||
{(agent) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
|
||||
{agent()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showModel && modelStatus()}>
|
||||
{(info) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
{info().model}
|
||||
<Show when={statuslineLayout().showVariant && info().variant}>
|
||||
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
|
||||
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint.label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={commandHint()}>
|
||||
{(hint) => (
|
||||
<box backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint().label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1061,7 +1007,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
borderColor={theme().border}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: props.mono ? "|" : "┃",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function footerWidthPolicy(width: number) {
|
||||
return {
|
||||
dialog: {
|
||||
@@ -6,49 +9,120 @@ export function footerWidthPolicy(width: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE_HEADROOM = 8
|
||||
type FooterAction = {
|
||||
key: string
|
||||
label: string
|
||||
expanded?: string
|
||||
}
|
||||
|
||||
export type FooterStatuslineGroup = {
|
||||
id:
|
||||
| "spinner"
|
||||
| "status"
|
||||
| "escape"
|
||||
| "queued"
|
||||
| "subagents"
|
||||
| "background"
|
||||
| "agent"
|
||||
| "model"
|
||||
| "context"
|
||||
| "cost"
|
||||
| "provider"
|
||||
| "menu"
|
||||
parts: Array<{ text: string; tone: "text" | "muted" | "agent" | "status" }>
|
||||
}
|
||||
|
||||
export function footerStatuslinePolicy(input: {
|
||||
width: number
|
||||
mainWidth: number
|
||||
commandWidth?: number
|
||||
agentWidth?: number
|
||||
contextWidths: number[]
|
||||
modelWidth?: number
|
||||
variantWidth?: number
|
||||
usageWidth?: number
|
||||
mono?: boolean
|
||||
status?: { text: string; expanded?: string }
|
||||
escape?: FooterAction
|
||||
work: Array<FooterAction & { id: "queued" | "subagents" | "background" }>
|
||||
model?: { name: string; variant?: string }
|
||||
agent?: string
|
||||
context?: { compact: string; full: string }
|
||||
cost?: string
|
||||
provider?: string
|
||||
menu?: FooterAction
|
||||
spinner?: string
|
||||
}) {
|
||||
let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
|
||||
let hasSection = input.commandWidth !== undefined
|
||||
const include = (width: number | undefined, headroom = 0) => {
|
||||
if (width === undefined) return false
|
||||
const required = width + (hasSection ? 3 : 1)
|
||||
if (remaining < required + headroom) return false
|
||||
remaining -= required
|
||||
hasSection = true
|
||||
return true
|
||||
const group = (
|
||||
id: FooterStatuslineGroup["id"],
|
||||
text: string,
|
||||
tone: FooterStatuslineGroup["parts"][number]["tone"] = "muted",
|
||||
): FooterStatuslineGroup => ({ id, parts: [{ text, tone }] })
|
||||
const action = (id: FooterStatuslineGroup["id"], value: FooterAction, expanded = false): FooterStatuslineGroup => ({
|
||||
id,
|
||||
parts: [
|
||||
{ text: value.key, tone: "text" },
|
||||
{ text: ` ${expanded ? (value.expanded ?? value.label) : value.label}`, tone: "muted" },
|
||||
],
|
||||
})
|
||||
const identity = (cells: number) => {
|
||||
const name = input.model!.name
|
||||
const ellipsis = input.mono ? "..." : "\u2026"
|
||||
const text =
|
||||
stringWidth(name) > cells + stringWidth(ellipsis) ? Locale.takeWidth(name, cells).trimEnd() + ellipsis : name
|
||||
return group("model", text + (input.model!.variant ? ` [${input.model!.variant}]` : ""), "text")
|
||||
}
|
||||
const selected = new Map<FooterStatuslineGroup["id"], FooterStatuslineGroup>()
|
||||
if (input.spinner) selected.set("spinner", group("spinner", input.spinner, "text"))
|
||||
if (input.status?.text) selected.set("status", group("status", input.status.text, "status"))
|
||||
if (input.escape) selected.set("escape", action("escape", input.escape))
|
||||
|
||||
const showModel = include(input.modelWidth)
|
||||
const showAgent = include(input.agentWidth)
|
||||
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
|
||||
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
|
||||
const contextComplete = contextCount === input.contextWidths.length
|
||||
const variantWidth = input.variantWidth
|
||||
const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
|
||||
if (showVariant) remaining -= variantWidth
|
||||
const showUsage =
|
||||
(showModel || input.modelWidth === undefined) &&
|
||||
(showAgent || input.agentWidth === undefined) &&
|
||||
contextComplete &&
|
||||
(showVariant || input.variantWidth === undefined) &&
|
||||
include(input.usageWidth, USAGE_HEADROOM)
|
||||
|
||||
return {
|
||||
showAgent,
|
||||
contextCount,
|
||||
showModel,
|
||||
showVariant,
|
||||
showUsage,
|
||||
const place = () => {
|
||||
const order: FooterStatuslineGroup["id"][] = [
|
||||
"spinner",
|
||||
"status",
|
||||
"escape",
|
||||
"queued",
|
||||
"subagents",
|
||||
"background",
|
||||
"agent",
|
||||
"model",
|
||||
"context",
|
||||
"cost",
|
||||
"provider",
|
||||
"menu",
|
||||
]
|
||||
const groups = order.flatMap((id) => selected.get(id) ?? [])
|
||||
const separator = input.mono ? " - " : " \u00b7 "
|
||||
return {
|
||||
groups,
|
||||
text: groups
|
||||
.map(
|
||||
(item, index) =>
|
||||
(index === 0 ? "" : groups[index - 1]!.id === "spinner" ? " " : separator) +
|
||||
item.parts.map((part) => part.text).join(""),
|
||||
)
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
let layout = place()
|
||||
// Required controls may wrap. Optional information never crowds that fallback.
|
||||
if (stringWidth(layout.text) > input.width || layout.text.includes("\n")) return layout
|
||||
|
||||
// Each stage retains earlier information. Stop at the first non-fitting stage:
|
||||
// backfilling shorter, lower-priority groups would make resizing unstable.
|
||||
const stages = [
|
||||
...input.work.map((item) => action(item.id, item)),
|
||||
...(input.model ? [identity(8)] : []),
|
||||
...(input.agent ? [group("agent", input.agent, "agent")] : []),
|
||||
...(input.context ? [group("context", input.context.compact)] : []),
|
||||
...(input.model ? [identity(24)] : []),
|
||||
...(input.context ? [group("context", input.context.full)] : []),
|
||||
...(input.cost ? [group("cost", input.cost)] : []),
|
||||
...(input.provider ? [group("provider", input.provider)] : []),
|
||||
...(input.menu ? [action("menu", input.menu)] : []),
|
||||
...(input.model ? [identity(Infinity)] : []),
|
||||
...input.work.filter((item) => item.expanded).map((item) => action(item.id, item, true)),
|
||||
...(input.status?.expanded ? [group("status", input.status.expanded, "status")] : []),
|
||||
]
|
||||
for (const stage of stages) {
|
||||
selected.set(stage.id, stage)
|
||||
const next = place()
|
||||
if (stringWidth(next.text) > input.width || next.text.includes("\n")) break
|
||||
layout = next
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ export function isCompactCommand(input: string): boolean {
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const list = (items ?? [])
|
||||
.filter((item) => item.text.trim().length > 0 || item.parts.some((part) => part.type === "file"))
|
||||
.map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
for (const item of list) {
|
||||
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
|
||||
@@ -76,7 +78,7 @@ export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
}
|
||||
|
||||
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
|
||||
if (!prompt.text.trim()) {
|
||||
if (!prompt.text.trim() && !prompt.parts.some((part) => part.type === "file")) {
|
||||
return state
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
|
||||
turn_summary: config?.mini?.turn_summary ?? "show",
|
||||
footer: config?.mini?.footer ?? "show",
|
||||
splash: config?.mini?.splash ?? "show",
|
||||
work_spinner: config?.mini?.work_spinner ?? "block-soft-slide",
|
||||
mono: config?.mini?.mono ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import path from "path"
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
buildKittyKeyboardFlags,
|
||||
createCliRenderer,
|
||||
type CliRenderer,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import { monoSnapshot } from "./mono"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { entrySplash, exitSplash } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
@@ -81,7 +87,7 @@ export type Lifecycle = {
|
||||
onResize(fn: () => void): () => void
|
||||
refreshTheme(): void
|
||||
setTitle(title?: string): void
|
||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||
resetForReplay(): Promise<void>
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||
}
|
||||
|
||||
@@ -106,19 +112,9 @@ function shutdown(renderer: CliRenderer): void {
|
||||
}
|
||||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !isFallbackTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
}
|
||||
}
|
||||
|
||||
const next = history.find((item) => item.text.trim().length > 0)
|
||||
return {
|
||||
title: next?.text ?? title,
|
||||
showSession: !!next,
|
||||
}
|
||||
function splashTitle(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !isFallbackTitle(title)) return title
|
||||
return history.find((item) => item.text.trim().length > 0)?.text ?? title
|
||||
}
|
||||
|
||||
function directoryLabel(directory: string, home: string) {
|
||||
@@ -188,27 +184,25 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const splash = splashInfo(input.sessionTitle, input.history)
|
||||
const meta = splashMeta({
|
||||
title: splash.title,
|
||||
session_id: input.sessionID,
|
||||
mono,
|
||||
})
|
||||
const startup =
|
||||
miniSettings.splash === "show" && !mono && tuiConfig.animations !== false
|
||||
? { version: input.host.version, detail: directoryLabel(input.getDirectory(), input.host.paths.home) }
|
||||
: undefined
|
||||
const wrote = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
miniSettings.splash === "show"
|
||||
miniSettings.splash === "show" && !startup
|
||||
? entrySplash({
|
||||
...meta,
|
||||
version: input.host.version,
|
||||
theme: theme.splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||
mono,
|
||||
})
|
||||
: undefined,
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||
|
||||
const { RunFooter } = await footerTask
|
||||
let closed = false
|
||||
@@ -227,15 +221,22 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
mono,
|
||||
// The transcript always starts one row below the terminal's prior output,
|
||||
// even when the entry splash itself is hidden.
|
||||
wrote: wrote || miniSettings.splash === "hide",
|
||||
wrote: wrote || !!startup || miniSettings.splash === "hide",
|
||||
startup,
|
||||
tuiConfig,
|
||||
miniSettings: {
|
||||
current: miniSettings,
|
||||
update: input.onMiniSettingChange,
|
||||
},
|
||||
onMonoChange: (mono) => {
|
||||
if (mono) {
|
||||
renderer.disableKittyKeyboard()
|
||||
return
|
||||
}
|
||||
renderer.enableKittyKeyboard(buildKittyKeyboardFlags({ events: input.host.platform === "win32" }))
|
||||
},
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onFormReply: input.onFormReply,
|
||||
onFormCancel: input.onFormCancel,
|
||||
@@ -311,23 +312,20 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
let wroteExit = false
|
||||
|
||||
try {
|
||||
footer.finishStartup()
|
||||
await footer.idle().catch(() => {})
|
||||
|
||||
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") {
|
||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
||||
wroteExit = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: sessionID,
|
||||
mono,
|
||||
}),
|
||||
title: splashTitle(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history),
|
||||
session_id: sessionID,
|
||||
theme: footer.currentTheme().splash,
|
||||
mono,
|
||||
mono: footer.currentMiniSettings().mono,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
@@ -337,7 +335,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
if (input.host.platform === "linux") renderer.setTerminalTitle("")
|
||||
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||
shutdown(renderer)
|
||||
if (!wroteExit) {
|
||||
input.host.stdout.write("\n")
|
||||
@@ -366,7 +363,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
renderer.on(CliRenderEvents.RESIZE, resize)
|
||||
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
||||
},
|
||||
async resetForReplay(next) {
|
||||
async resetForReplay() {
|
||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
@@ -378,18 +375,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
|
||||
footer.resetForReplay(true)
|
||||
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
||||
renderer.writeToScrollback(
|
||||
entrySplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||
mono,
|
||||
}),
|
||||
version: input.host.version,
|
||||
theme: footer.currentTheme().splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||
mono,
|
||||
mono: footer.currentMiniSettings().mono,
|
||||
}),
|
||||
)
|
||||
renderer.requestRender()
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
break
|
||||
}
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
if (sent.mode !== "shell" && sent.text.trim()) {
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
@@ -252,7 +252,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
}
|
||||
|
||||
const submit = (prompt: RunPrompt) => {
|
||||
if (!prompt.text.trim() || state.closed) {
|
||||
if (
|
||||
state.closed ||
|
||||
(!prompt.text.trim() && (prompt.mode === "shell" || !prompt.parts.some((part) => part.type === "file")))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
if (state.demo?.interrupt()) return true
|
||||
if (!state.sessionID) {
|
||||
return false
|
||||
}
|
||||
@@ -411,7 +412,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
const thinking = () => input.thinking ?? configState.current.thinking === "show"
|
||||
const footer = shell.footer
|
||||
const firstPaint = footer.idle().catch(() => {})
|
||||
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
|
||||
const offRuntimeClose = footer.onClose(() => {
|
||||
state.demo?.interrupt()
|
||||
runtimeController.abort()
|
||||
})
|
||||
let clientGeneration = 0
|
||||
let clientController = new AbortController()
|
||||
let modelAttempt: AbortController | undefined
|
||||
@@ -480,11 +484,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
footer.event({ type: "history", history: resumed.history })
|
||||
footer.event({ type: "first", first: resumed.first })
|
||||
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||
await shell.resetForReplay({
|
||||
sessionTitle: state.sessionTitle,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
})
|
||||
await shell.resetForReplay()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||
@@ -848,12 +848,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
.then((item) =>
|
||||
item.handle.replayOnResize({
|
||||
localRows: () => state.localRows,
|
||||
reset: () =>
|
||||
shell.resetForReplay({
|
||||
sessionTitle: state.sessionTitle,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
}),
|
||||
reset: () => shell.resetForReplay(),
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
@@ -986,7 +981,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
phase: "idle",
|
||||
usage: "",
|
||||
usage: undefined,
|
||||
first: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +32,6 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
|
||||
if (commit.phase === "final") {
|
||||
return {
|
||||
fg: theme.system.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +48,6 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
|
||||
if (commit.kind === "reasoning") {
|
||||
return {
|
||||
fg: theme.reasoning.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Retained streaming append logic for direct-mode scrollback.
|
||||
//
|
||||
// Static entries are rendered through `scrollback.writer.tsx`. This file only
|
||||
// keeps the retained-surface machinery needed for streaming assistant,
|
||||
// reasoning, and tool progress entries that need stable markdown/code layout
|
||||
// while content is still arriving.
|
||||
// Static text entries are rendered through `scrollback.writer.tsx`. Retained
|
||||
// surfaces keep streaming markdown/code layout stable and wait for image loads
|
||||
// before their snapshots enter scrollback.
|
||||
import {
|
||||
CodeRenderable,
|
||||
ImageRenderable,
|
||||
MarkdownRenderable,
|
||||
TextRenderable,
|
||||
getTreeSitterClient,
|
||||
@@ -87,10 +87,13 @@ export class RunScrollbackStream {
|
||||
private tail: StreamCommit | undefined
|
||||
private rendered: StreamCommit | undefined
|
||||
private active: ActiveEntry | undefined
|
||||
private imageSurface: ScrollbackSurface | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
private wrote: boolean
|
||||
private shellOutput: () => boolean
|
||||
private mono: boolean
|
||||
private imagePreview: boolean
|
||||
private destroyed = false
|
||||
private pendingThemes: RunTheme[] = []
|
||||
|
||||
constructor(
|
||||
@@ -102,17 +105,34 @@ export class RunScrollbackStream {
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
shellOutput?: () => boolean
|
||||
mono?: boolean
|
||||
imagePreview?: boolean
|
||||
} = {},
|
||||
) {
|
||||
this.treeSitterClient = options.treeSitterClient
|
||||
this.wrote = options.wrote ?? false
|
||||
this.shellOutput = options.shellOutput ?? (() => true)
|
||||
this.mono = options.mono ?? false
|
||||
this.imagePreview = options.imagePreview ?? false
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
|
||||
private onThemeRelease: ((theme: RunTheme) => void) | undefined
|
||||
|
||||
public async setMono(mono: boolean): Promise<void> {
|
||||
if (this.mono === mono) return
|
||||
const active = this.active
|
||||
if (active?.body.type !== "markdown") await this.complete()
|
||||
this.mono = mono
|
||||
if (active?.body.type !== "markdown") return
|
||||
|
||||
// Rebuild the Markdown tree, keeping its source and printed block boundary.
|
||||
// Mono hooks are one-way, and ending the entry would lose open fence/list context.
|
||||
const next = this.createEntry(active.commit, active.body)
|
||||
this.active = { ...active, surface: next.surface, renderable: next.renderable }
|
||||
active.surface.destroy()
|
||||
this.releasePendingThemes()
|
||||
}
|
||||
|
||||
private releasePendingThemes(): void {
|
||||
if (this.pendingThemes.length === 0) {
|
||||
return
|
||||
@@ -348,11 +368,77 @@ export class RunScrollbackStream {
|
||||
}
|
||||
}
|
||||
|
||||
private async writeImage(commit: StreamCommit): Promise<void> {
|
||||
const surface = this.renderer.createScrollbackSurface(entryFlags(commit))
|
||||
this.imageSurface = surface
|
||||
|
||||
try {
|
||||
const image = new ImageRenderable(surface.renderContext, {
|
||||
source: commit.image,
|
||||
fit: "fit",
|
||||
visible: false,
|
||||
alignSelf: "flex-start",
|
||||
flexShrink: 0,
|
||||
})
|
||||
surface.root.add(image)
|
||||
// settle() waits for code highlighting, not image decoding.
|
||||
await image.loadPromise
|
||||
if (surface.isDestroyed) return
|
||||
|
||||
const body = entryBody(commit, { mono: this.mono })
|
||||
if (body.type !== "text") return
|
||||
const style = entryLook(commit, this.theme.entry)
|
||||
const caption = new TextRenderable(surface.renderContext, {
|
||||
content: body.content + (image.image ? "" : "\nNo preview"),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: style.fg,
|
||||
attributes: style.attrs,
|
||||
flexShrink: 0,
|
||||
})
|
||||
surface.root.add(caption, 0)
|
||||
surface.render()
|
||||
|
||||
if (image.image && !this.mono) {
|
||||
const resolution = this.renderer.resolution
|
||||
const fitted = image.getFittedSize(
|
||||
Math.min(
|
||||
surface.width,
|
||||
resolution?.width
|
||||
? Math.max(1, Math.floor((image.image.width * this.renderer.terminalWidth) / resolution.width))
|
||||
: Infinity,
|
||||
),
|
||||
Math.min(
|
||||
Math.max(0, this.renderer.terminalHeight - this.renderer.height - caption.height),
|
||||
resolution?.height
|
||||
? Math.max(1, Math.floor((image.image.height * this.renderer.terminalHeight) / resolution.height))
|
||||
: Infinity,
|
||||
),
|
||||
image.cellAspectRatio,
|
||||
)
|
||||
// fit centers within its rectangle, so the rectangle itself must be fitted.
|
||||
image.width = fitted.width
|
||||
image.height = fitted.height
|
||||
image.visible = fitted.width > 0 && fitted.height > 0
|
||||
surface.render()
|
||||
}
|
||||
|
||||
this.writeSpacer(separatorRows(this.rendered, commit, body) || (!this.rendered && this.wrote ? 1 : 0))
|
||||
surface.commitRows(0, surface.height, { trailingNewline: entryFlags(commit).trailingNewline })
|
||||
this.markRendered(commit)
|
||||
} finally {
|
||||
surface.destroy()
|
||||
this.imageSurface = undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async append(commit: StreamCommit): Promise<void> {
|
||||
if (this.destroyed || this.renderer.isDestroyed) return
|
||||
const same = sameEntryGroup(this.tail, commit)
|
||||
if (!same) {
|
||||
if (!same || commit.image) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
if (this.destroyed || this.renderer.isDestroyed) return
|
||||
|
||||
if (commit.summary) {
|
||||
this.writeSpacer(1)
|
||||
@@ -362,6 +448,12 @@ export class RunScrollbackStream {
|
||||
return
|
||||
}
|
||||
|
||||
if (commit.image && this.imagePreview && !this.mono) {
|
||||
await this.writeImage(commit)
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono })
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
@@ -426,6 +518,9 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyed = true
|
||||
this.imageSurface?.destroy()
|
||||
this.imageSurface = undefined
|
||||
this.resetActive()
|
||||
this.releasePendingThemes()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { For, Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
@@ -39,6 +39,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
|
||||
}
|
||||
|
||||
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
if (commit.image) return "block"
|
||||
if (commit.kind === "tool") {
|
||||
if (body.type === "structured" || body.type === "markdown") {
|
||||
return "block"
|
||||
@@ -253,17 +254,22 @@ export function RunEntryContent(props: {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={markdown()}>
|
||||
<markdown
|
||||
ref={(renderable: MarkdownRenderable) => {
|
||||
if (props.opts?.mono) monoMarkdownRenderable(renderable)
|
||||
}}
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={markdown()!.content}
|
||||
fg={color()}
|
||||
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
|
||||
/>
|
||||
{/* Mono hooks mutate renderables, so toggling needs a fresh Markdown leaf. */}
|
||||
<For each={[props.opts?.mono === true]}>
|
||||
{(mono) => (
|
||||
<markdown
|
||||
ref={(renderable: MarkdownRenderable) => {
|
||||
if (mono) monoMarkdownRenderable(renderable)
|
||||
}}
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={markdown()!.content}
|
||||
fg={color()}
|
||||
tableOptions={mono ? monoMarkdownTableOptions : { widthMode: "content" }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { projectedPromptInput } from "../prompt/codec"
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
@@ -20,30 +21,39 @@ export type RunSession = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function messagePrompt(message: SessionMessageUser): RunPrompt {
|
||||
export function messagePrompt(message: Pick<SessionMessageUser, "text" | "files" | "agents" | "skills">): RunPrompt {
|
||||
const input = projectedPromptInput(message)
|
||||
return {
|
||||
text: message.text,
|
||||
text: input.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
...(input.files ?? []).map((file, index) => ({
|
||||
type: "file" as const,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
url: file.uri,
|
||||
mime: message.files?.[index]?.mime,
|
||||
filename: file.name,
|
||||
...(file.description === undefined ? {} : { description: file.description }),
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file",
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
path: file.name ?? (file.uri.startsWith("data:") ? "inline attachment" : file.uri),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
...(input.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
...(input.skills ?? []).map((skill) => ({
|
||||
type: "skill" as const,
|
||||
id: skill.id,
|
||||
source: skill.mention
|
||||
? { start: skill.mention.start, end: skill.mention.end, value: skill.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -92,7 +102,7 @@ function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
|
||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
return session.turns
|
||||
.map((turn) => turn.prompt)
|
||||
.filter((prompt) => prompt.text.trim())
|
||||
.filter((prompt) => prompt.text.trim() || prompt.parts.some((part) => part.type === "file"))
|
||||
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
|
||||
.map(promptCopy)
|
||||
.slice(-limit)
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
// Entry and exit splash banners for direct interactive mode scrollback.
|
||||
//
|
||||
// Renders the full opencode entry logo and a compact [O] exit badge, plus
|
||||
// session metadata and the resume command. These are scrollback snapshots, so
|
||||
// they become immutable terminal history once committed.
|
||||
//
|
||||
// Both variants use a cell-based renderer. cells() classifies each character
|
||||
// in the source template as text, full-block, half-block-mix, or
|
||||
// half-block-top, and draw() renders it with foreground/background shadow
|
||||
// colors from the theme.
|
||||
// The entry header is a single flex row; the exit banner retains its cell-based
|
||||
// logo and resume information. Both become immutable terminal history.
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
StyledText,
|
||||
fg,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { go } from "../logo"
|
||||
import { monoTruncate, monoTruncateMiddle } from "./mono"
|
||||
import { monoTruncate } from "./mono"
|
||||
import type { RunSplashTheme } from "./theme"
|
||||
|
||||
const SPLASH_TITLE_LIMIT = 50
|
||||
@@ -34,7 +31,6 @@ type SplashInput = {
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunSplashTheme
|
||||
showSession?: boolean
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
@@ -173,52 +169,23 @@ function draw(
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
function buildExit(input: SplashWriterInput, ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const meta = splashMeta(input)
|
||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
||||
const left = input.theme.left
|
||||
const right = input.theme.right
|
||||
const leftShadow = input.theme.leftShadow
|
||||
let height = 1
|
||||
|
||||
if (kind === "entry") {
|
||||
const mark = input.mono ? ["[O]"] : go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
|
||||
for (let i = 0; i < mark.length; i += 1) {
|
||||
draw(lines, mark[i] ?? "", {
|
||||
left: 0,
|
||||
top: top + i,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
}
|
||||
|
||||
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
|
||||
if (input.detail) {
|
||||
push(
|
||||
lines,
|
||||
body_left,
|
||||
top + 1,
|
||||
input.mono
|
||||
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
|
||||
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
|
||||
left,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
height = top + Math.max(mark.length, input.detail ? 2 : 1)
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const mark = input.mono ? ["[O]"] : go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
const session = "Session "
|
||||
const label = "Continue "
|
||||
const mark = input.mono ? ["[O]"] : go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
const session = "Session "
|
||||
const label = "Continue "
|
||||
const command = `opencode mini -s ${meta.session_id}`
|
||||
const wide = body_left + stringWidth(label + command) <= width
|
||||
const commandHeight = wide ? 1 : Math.ceil(stringWidth(command) / width)
|
||||
|
||||
if (wide) {
|
||||
for (let i = 0; i < mark.length; i += 1) {
|
||||
draw(lines, mark[i] ?? "", {
|
||||
left: 0,
|
||||
@@ -229,23 +196,13 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
}
|
||||
|
||||
if (input.showSession !== false) {
|
||||
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
|
||||
push(lines, body_left, top, session, left)
|
||||
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
|
||||
}
|
||||
|
||||
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
body_left + label.length,
|
||||
top + 1,
|
||||
`opencode mini -s ${meta.session_id}`,
|
||||
right,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
height = top + Math.max(mark.length, 2)
|
||||
push(lines, body_left, top + 1, label, left)
|
||||
}
|
||||
|
||||
const height = top + (wide ? Math.max(mark.length, 2) : commandHeight)
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
@@ -257,6 +214,19 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
position: "absolute",
|
||||
left: wide ? body_left + label.length : 0,
|
||||
top: wide ? top + 1 : top,
|
||||
width: wide ? width - body_left - label.length : width,
|
||||
height: commandHeight,
|
||||
wrapMode: "char",
|
||||
content: command,
|
||||
fg: right,
|
||||
attributes: TextAttributes.BOLD,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -275,10 +245,63 @@ export function splashMeta(input: SplashInput): SplashMeta {
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
export function entrySplash(input: {
|
||||
version: string
|
||||
detail?: string
|
||||
mono?: boolean
|
||||
theme: RunSplashTheme
|
||||
}): ScrollbackWriter {
|
||||
return (ctx) => {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const layout = entrySplashLayout({ ...input, width })
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
width,
|
||||
height: 2,
|
||||
paddingTop: 1,
|
||||
flexDirection: "row",
|
||||
overflow: "hidden",
|
||||
})
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
content: new StyledText([fg(input.theme.right)(layout.label), fg(input.theme.left)(layout.metadata)]),
|
||||
width,
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
}),
|
||||
)
|
||||
return { root, width, height: 2, rowColumns: width, startOnNewLine: true, trailingNewline: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplashLayout(input: { width: number; version: string; detail?: string; mono?: boolean }) {
|
||||
const detail = input.detail ?? ""
|
||||
const segments = detail.split(/[/\\]/).filter(Boolean)
|
||||
const leaf = segments.at(-1) ?? detail
|
||||
const separator = input.mono ? " - " : " · "
|
||||
const ellipsis = input.mono ? "..." : "…"
|
||||
const slash = detail.includes("\\") ? "\\" : "/"
|
||||
const paths = segments
|
||||
.slice(1)
|
||||
.map((_, index) => ellipsis + slash + segments.slice(index + 1).join(slash))
|
||||
.reverse()
|
||||
.filter((path) => stringWidth(path) < stringWidth(detail))
|
||||
let layout = { label: Locale.takeWidth("oc mini", input.width), version: "", path: "", metadata: "" }
|
||||
const stages = [
|
||||
{ label: `${input.mono ? "[O]" : "▪"} oc mini` },
|
||||
...(leaf ? [{ path: leaf }] : []),
|
||||
...(input.version ? [{ version: input.version }] : []),
|
||||
...paths.concat(detail ? [detail] : []).map((path) => ({ path })),
|
||||
]
|
||||
// Stop at the first non-fitting stage instead of backfilling lower-priority metadata.
|
||||
for (const stage of stages) {
|
||||
const next = { ...layout, ...stage }
|
||||
const metadata = (next.version ? ` v${next.version}` : "") + (next.path ? separator + next.path : "")
|
||||
if (stringWidth(next.label + metadata) > input.width) break
|
||||
layout = { ...next, metadata }
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
return (ctx) => buildExit(input, ctx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { deduplicateVisibleImages } from "../prompt/attachment"
|
||||
import { toolDisplayContent } from "../util/tool-display"
|
||||
import type { StreamCommit } from "./types"
|
||||
|
||||
export type ImageCommit = StreamCommit & { image: string; messageID: string; partID: string }
|
||||
|
||||
export function userImageCommits(messageID: string, files: SessionMessageUser["files"]): ImageCommit[] {
|
||||
return deduplicateVisibleImages(files ?? [])
|
||||
.filter((file) => file.mime.startsWith("image/"))
|
||||
.map((file, index) => ({
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: file.name ?? file.mention?.text ?? `[Image ${index + 1}]`,
|
||||
image: `data:${file.mime};base64,${file.data}`,
|
||||
phase: "final",
|
||||
messageID,
|
||||
partID: `image:${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function toolImageCommits(part: SessionMessageAssistantTool, messageID: string): ImageCommit[] {
|
||||
return toolDisplayContent(part.state)
|
||||
.flatMap((content) =>
|
||||
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||
? [{ uri: content.uri, name: content.name }]
|
||||
: [],
|
||||
)
|
||||
.map((content, index) => ({
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
text: content.name ?? `[Image ${index + 1}]`,
|
||||
image: content.uri,
|
||||
phase: "final",
|
||||
messageID,
|
||||
partID: `prt_${part.id}:image:${index}`,
|
||||
}))
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import type {
|
||||
PermissionRequest,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Locale } from "../util/locale"
|
||||
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||
import { toolImageCommits, userImageCommits } from "./stream-v2.image"
|
||||
import type {
|
||||
FooterSubagentDetail,
|
||||
FooterSubagentState,
|
||||
@@ -115,7 +117,7 @@ type ChildState = {
|
||||
permissions: MiniPermissionRequest[]
|
||||
forms: MiniFormRequest[]
|
||||
messageIDs: Set<string>
|
||||
prompts: Map<string, string>
|
||||
prompts: Map<string, Pick<SessionMessageUser, "text" | "files">>
|
||||
hydrated: boolean
|
||||
detailStale: boolean
|
||||
blockersHydrated: boolean
|
||||
@@ -287,16 +289,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
if (meta.background === true) child.background = true
|
||||
}
|
||||
|
||||
const userFrame = (child: ChildState, messageID: string, value: string) => {
|
||||
const userFrame = (child: ChildState, messageID: string, prompt: Pick<SessionMessageUser, "text" | "files">) => {
|
||||
if (child.messageIDs.has(messageID)) return false
|
||||
child.messageIDs.add(messageID)
|
||||
setFrame(child, `user:${messageID}`, {
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: value,
|
||||
phase: "start",
|
||||
messageID,
|
||||
})
|
||||
if (prompt.text.trim())
|
||||
setFrame(child, `user:${messageID}`, {
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
messageID,
|
||||
})
|
||||
for (const commit of userImageCommits(messageID, prompt.files))
|
||||
setFrame(child, sourceKey(messageID, commit.partID), commit)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -325,12 +330,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
child.finishedTools.add(key)
|
||||
child.tools.delete(key)
|
||||
if (part.state.status === "error" && output) {
|
||||
setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
|
||||
setFrame(child, `${frame}:final`, toolCommit(part, messageID, "final", undefined, input.directory))
|
||||
return
|
||||
}
|
||||
setFrame(child, frame, toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory))
|
||||
const partial = part.state.status === "error" && output
|
||||
if (partial) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
|
||||
setFrame(
|
||||
child,
|
||||
partial ? `${frame}:final` : frame,
|
||||
toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory),
|
||||
)
|
||||
for (const commit of toolImageCommits(part, messageID)) setFrame(child, sourceKey(messageID, commit.partID), commit)
|
||||
}
|
||||
|
||||
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
||||
@@ -342,7 +349,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
for (const message of messages) {
|
||||
if (message.type === "user") {
|
||||
child.prompts.delete(message.id)
|
||||
userFrame(child, message.id, message.text)
|
||||
userFrame(child, message.id, message)
|
||||
continue
|
||||
}
|
||||
if (message.type !== "assistant") continue
|
||||
@@ -398,9 +405,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
const pendingPrompts = new Map(child.prompts)
|
||||
const pendingTools = new Map(child.tools)
|
||||
let retry = false
|
||||
const task = sdk.message
|
||||
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal })
|
||||
.then((response) => {
|
||||
const task = Promise.all([
|
||||
sdk.message.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal }),
|
||||
sdk.session.inbox.list({ sessionID: child.sessionID }, { signal }),
|
||||
])
|
||||
.then(([response, pending]) => {
|
||||
if (!active(signal)) return
|
||||
const buffered = hydrationEvents.get(child.sessionID) ?? []
|
||||
hydrationEvents.delete(child.sessionID)
|
||||
@@ -410,6 +419,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
for (const item of pending) {
|
||||
if (item.type === "user") child.prompts.set(item.id, item.payload)
|
||||
}
|
||||
for (const [id, prompt] of pendingPrompts) {
|
||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
||||
}
|
||||
@@ -645,7 +657,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
|
||||
const reduce = (child: ChildState, event: V2Event) => {
|
||||
if (event.type === "session.inbox.enqueued") {
|
||||
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload.text)
|
||||
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.inbox.delivered") {
|
||||
|
||||
@@ -6,19 +6,22 @@ import type {
|
||||
PermissionRequest,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
SessionInboxInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { formatContextUsage } from "../util/session"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||
import { toolImageCommits, userImageCommits, type ImageCommit } from "./stream-v2.image"
|
||||
import { messagePrompt } from "./session.shared"
|
||||
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
import { normalizeTool, toolOutputText } from "./tool"
|
||||
import { toolDisplayContent } from "../util/tool-display"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterPatch,
|
||||
FooterView,
|
||||
LocalReplayRow,
|
||||
MiniPermissionRequest,
|
||||
@@ -123,12 +126,17 @@ type ToolState = {
|
||||
started: boolean
|
||||
}
|
||||
|
||||
type PendingPrompt = FooterQueuedPrompt & { files: SessionMessageUser["files"] }
|
||||
|
||||
type State = {
|
||||
permissions: MiniPermissionRequest[]
|
||||
forms: MiniFormRequest[]
|
||||
globalForms: MiniFormRequest[]
|
||||
view: FooterView
|
||||
messageIDs: Set<string>
|
||||
// Optimistic text and pending steers can be visible before attachment delivery.
|
||||
promotedMessages: Set<string>
|
||||
imageIDs: Set<string>
|
||||
fragments: FragmentReconciler
|
||||
tools: Map<string, ToolState>
|
||||
toolSources: Map<string, SessionMessageAssistantTool>
|
||||
@@ -147,14 +155,12 @@ type State = {
|
||||
executionEpoch: number
|
||||
buffered?: ReplayBuffer
|
||||
errors: Set<string>
|
||||
pending: Map<string, FooterQueuedPrompt>
|
||||
pending: Map<string, PendingPrompt>
|
||||
admitted: Set<string>
|
||||
stepModel: RunInput["model"]
|
||||
activeCompaction?: string
|
||||
}
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
|
||||
export function formatUnknownError(error: unknown): string {
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message || error.name
|
||||
@@ -184,11 +190,12 @@ function errorMessage(error: { message?: string; _tag?: string }) {
|
||||
return error.message || error._tag || "Session execution failed"
|
||||
}
|
||||
|
||||
function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
|
||||
function pendingPrompt(item: SessionInboxInfo): PendingPrompt | undefined {
|
||||
if (item.type !== "user") return undefined
|
||||
return {
|
||||
messageID: item.id,
|
||||
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
|
||||
prompt: { messageID: item.id, ...messagePrompt(item.payload) },
|
||||
files: item.payload.files,
|
||||
delivery: item.delivery,
|
||||
...(item.payload.skills?.length
|
||||
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
|
||||
@@ -253,6 +260,7 @@ function promptFiles(next: SessionTurnInput) {
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
...(part.description === undefined ? {} : { description: part.description }),
|
||||
mention: promptFileMention(part),
|
||||
},
|
||||
]
|
||||
@@ -474,6 +482,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
globalForms: [],
|
||||
view: { type: "prompt" },
|
||||
messageIDs: new Set(),
|
||||
promotedMessages: new Set(),
|
||||
imageIDs: new Set(),
|
||||
fragments: createFragmentReconciler(),
|
||||
tools: new Map(),
|
||||
toolSources: new Map(),
|
||||
@@ -532,7 +542,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
write([], { phase: "idle", status })
|
||||
}
|
||||
|
||||
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
|
||||
const write = (commits: StreamCommit[], patch?: Pick<FooterPatch, "phase" | "status" | "usage">) => {
|
||||
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
|
||||
if (!state.initial && state.buffered === undefined)
|
||||
commits.forEach((commit) => {
|
||||
@@ -562,9 +572,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
|
||||
const freshImages = (commits: ImageCommit[], render = true) =>
|
||||
commits.filter((commit) => {
|
||||
const key = streamPartKey(commit.messageID, commit.partID)
|
||||
if (state.imageIDs.has(key)) return false
|
||||
state.imageIDs.add(key)
|
||||
return render
|
||||
})
|
||||
|
||||
const mergePending = (item: SessionInboxInfo) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
if (!prompt || state.messageIDs.has(prompt.messageID)) return
|
||||
if (!prompt) return
|
||||
if (state.promotedMessages.has(prompt.messageID)) {
|
||||
write(freshImages(userImageCommits(prompt.messageID, prompt.files)))
|
||||
return
|
||||
}
|
||||
state.admitted.add(prompt.messageID)
|
||||
state.pending.set(prompt.messageID, prompt)
|
||||
syncPending()
|
||||
@@ -655,12 +677,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.finishedTools.add(key)
|
||||
state.tools.delete(key)
|
||||
if (!sourcePending(key)) state.toolSources.delete(key)
|
||||
const images = freshImages(toolImageCommits(part, messageID), render)
|
||||
if (!render) return
|
||||
const phase = toolFinalPhase(part)
|
||||
if (part.state.status === "error" && delta)
|
||||
write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
|
||||
write([
|
||||
toolCommit(part, messageID, phase, phase === "progress" ? delta : undefined, input.location?.directory, version),
|
||||
...images,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -671,13 +695,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (state.wait && (admitted || (waiting && state.wait.failureMessageID === message.id)))
|
||||
promoteWait(state.wait, false, message.id)
|
||||
if (state.pending.delete(message.id)) syncPending()
|
||||
if (state.messageIDs.has(message.id)) return
|
||||
const visible = state.messageIDs.has(message.id) || (reuseVisibleWait && waiting)
|
||||
state.messageIDs.add(message.id)
|
||||
state.promotedMessages.add(message.id)
|
||||
const images = freshImages(userImageCommits(message.id, message.files), render)
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
write([
|
||||
...skillCommits(message.id, message.skills),
|
||||
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
|
||||
...(!visible ? skillCommits(message.id, message.skills) : []),
|
||||
...(!visible && message.text.trim()
|
||||
? [{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id } as const]
|
||||
: []),
|
||||
...images,
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -960,13 +988,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const waiting = state.wait?.messageID === event.data.inboxID
|
||||
if (state.wait) promoteWait(state.wait, true, event.data.inboxID)
|
||||
state.admitted.delete(event.data.inboxID)
|
||||
state.promotedMessages.add(event.data.inboxID)
|
||||
const pending = state.pending.get(event.data.inboxID)
|
||||
state.pending.delete(event.data.inboxID)
|
||||
syncPending()
|
||||
const visible = state.messageIDs.has(event.data.inboxID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
|
||||
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
|
||||
if (!waiting && pending && !visible)
|
||||
if (!waiting && pending && !visible && pending.prompt.text.trim())
|
||||
commits.push({
|
||||
kind: "user",
|
||||
source: "system",
|
||||
@@ -974,6 +1003,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
})
|
||||
if (pending) commits.push(...freshImages(userImageCommits(event.data.inboxID, pending.files)))
|
||||
write(commits, { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
@@ -1291,9 +1321,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
event.data.tokens.cache.write
|
||||
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
|
||||
state.stepModel = undefined
|
||||
const usage = total > 0 ? formatContextUsage(total, limit ? Math.round((total / limit) * 100) : undefined) : ""
|
||||
write([], {
|
||||
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
||||
usage:
|
||||
total > 0 || event.data.cost
|
||||
? {
|
||||
tokens: total,
|
||||
percent: limit ? Math.round((total / limit) * 100) : undefined,
|
||||
cost: event.data.cost || undefined,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1583,6 +1619,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (!current(attempt)) return false
|
||||
reset = true
|
||||
state.messageIDs.clear()
|
||||
state.imageIDs.clear()
|
||||
state.fragments.clear()
|
||||
state.tools.clear()
|
||||
state.toolSources.clear()
|
||||
@@ -1603,6 +1640,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
try {
|
||||
if (reset) {
|
||||
for (const row of localRows) {
|
||||
if (row.commit.image && row.commit.messageID && row.commit.partID) {
|
||||
const key = streamPartKey(row.commit.messageID, row.commit.partID)
|
||||
if (state.imageIDs.has(key)) continue
|
||||
state.imageIDs.add(key)
|
||||
input.footer.append(row.commit)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
row.commit.messageID &&
|
||||
row.commit.partID &&
|
||||
@@ -1759,7 +1803,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await admitPrompt(next, client, next.prompt.delivery ?? "steer")
|
||||
const pending = await admitPrompt(next, client, next.prompt.delivery ?? "steer")
|
||||
if (pending) mergePending(pending)
|
||||
admitted?.()
|
||||
return
|
||||
}
|
||||
|
||||
+177
-452
@@ -1,19 +1,13 @@
|
||||
// Theme resolution for direct interactive mode.
|
||||
//
|
||||
// Derives scrollback and footer colors from the terminal's actual palette.
|
||||
// resolveRunTheme() queries the renderer for the terminal's palette,
|
||||
// detects dark/light mode, builds a small system theme locally, and maps it to
|
||||
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
|
||||
// palette if detection fails.
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme } from "@opencode-ai/theme/tui"
|
||||
import { allThemes, DEFAULT_THEMES, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { resolveThemeColors } from "../theme/resolve"
|
||||
import { terminalMode } from "../theme/system"
|
||||
import type { Theme, ThemeV1Json } from "../theme/v1"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
import { dedupeWith } from "effect/Array"
|
||||
import type { EntryKind, RunTuiConfig } from "./types"
|
||||
|
||||
type ThemeCurrent = Omit<Theme, "_hasSelectedListItemText">
|
||||
|
||||
type Tone = {
|
||||
body: ColorInput
|
||||
start?: ColorInput
|
||||
@@ -28,14 +22,24 @@ export type RunSplashTheme = {
|
||||
}
|
||||
|
||||
export type RunFooterTheme = {
|
||||
highlight: ColorInput
|
||||
selected: ColorInput
|
||||
selectedText: ColorInput
|
||||
actionSecondaryText: ColorInput
|
||||
actionFocusedBg: ColorInput
|
||||
actionFocusedText: ColorInput
|
||||
formfieldText: ColorInput
|
||||
formfieldFocusedBg: ColorInput
|
||||
formfieldFocusedText: ColorInput
|
||||
selection: ColorInput
|
||||
variant: ColorInput
|
||||
running: ColorInput
|
||||
question: ColorInput
|
||||
permission: ColorInput
|
||||
success: ColorInput
|
||||
link: ColorInput
|
||||
categorical: ColorInput[]
|
||||
warning: ColorInput
|
||||
error: ColorInput
|
||||
muted: ColorInput
|
||||
text: ColorInput
|
||||
status: ColorInput
|
||||
statusAccent: ColorInput
|
||||
shade: ColorInput
|
||||
surface: ColorInput
|
||||
@@ -67,83 +71,22 @@ export type RunTheme = {
|
||||
block: RunBlockTheme
|
||||
}
|
||||
|
||||
type ThemeColor = Exclude<keyof ThemeCurrent, "thinkingOpacity">
|
||||
|
||||
type SharedSyntaxTheme = ThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export const transparent = RGBA.fromValues(0, 0, 0, 0)
|
||||
|
||||
function alpha(color: RGBA, value: number): RGBA {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
|
||||
}
|
||||
|
||||
function rgba(hex: string, value?: number): RGBA {
|
||||
const color = RGBA.fromHex(hex)
|
||||
return value === undefined ? color : alpha(color, value)
|
||||
}
|
||||
|
||||
function colorMode(bg: RGBA): "dark" | "light" {
|
||||
return luminance(bg) > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA): number {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
|
||||
if (color.a === 0) {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
|
||||
}
|
||||
|
||||
const target = Math.min(limit, color.a * scale)
|
||||
const mix = Math.min(1, target / color.a)
|
||||
|
||||
return RGBA.fromValues(
|
||||
base.r + (color.r - base.r) * mix,
|
||||
base.g + (color.g - base.g) * mix,
|
||||
base.b + (color.b - base.b) * mix,
|
||||
color.a,
|
||||
)
|
||||
}
|
||||
|
||||
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
return RGBA.fromInts(
|
||||
Math.round((base.r + (overlay.r - base.r) * value) * 255),
|
||||
Math.round((base.g + (overlay.g - base.g) * value) * 255),
|
||||
Math.round((base.b + (overlay.b - base.b) * value) * 255),
|
||||
)
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
|
||||
})
|
||||
}
|
||||
const ansiPalette = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index, ansiToRgba(index)))
|
||||
const palettes = new WeakMap<CliRenderer, TerminalColors>()
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
if (value <= 0.04045) {
|
||||
return value / 12.92
|
||||
}
|
||||
|
||||
return ((value + 0.055) / 1.055) ** 2.4
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function oklab(color: RGBA) {
|
||||
const r = srgbToLinear(color.r)
|
||||
const g = srgbToLinear(color.g)
|
||||
const b = srgbToLinear(color.b)
|
||||
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||
|
||||
return {
|
||||
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
@@ -151,8 +94,8 @@ function oklab(color: RGBA) {
|
||||
}
|
||||
}
|
||||
|
||||
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
const target = oklab(rgba)
|
||||
function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
|
||||
const target = oklab(color)
|
||||
const hit = indexed.reduce(
|
||||
(best, item) => {
|
||||
const sample = oklab(item)
|
||||
@@ -160,352 +103,110 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
const da = sample.a - target.a
|
||||
const db = sample.b - target.b
|
||||
const dist = dl * dl * 2 + da * da + db * db
|
||||
if (dist >= best.dist) return best
|
||||
return {
|
||||
dist,
|
||||
item,
|
||||
}
|
||||
},
|
||||
{
|
||||
dist: Number.POSITIVE_INFINITY,
|
||||
item: indexed[0]!,
|
||||
return dist >= best.dist ? best : { dist, item }
|
||||
},
|
||||
{ dist: Number.POSITIVE_INFINITY, item: indexed[0]! },
|
||||
)
|
||||
|
||||
return RGBA.clone(hit.item)
|
||||
}
|
||||
|
||||
function paletteColor(colors: TerminalColors, index: number): RGBA {
|
||||
const value = colors.palette[index]
|
||||
return value ? RGBA.fromHex(value) : ansiToRgba(index)
|
||||
}
|
||||
|
||||
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
const mixed = tint(base, overlay, value)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): ThemeCurrent {
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
...resolved.theme,
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
|
||||
const r = bg.r * 255
|
||||
const g = bg.g * 255
|
||||
const b = bg.b * 255
|
||||
const lum = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
const cast = 0.25 * (1 - chroma(bg)) ** 2
|
||||
|
||||
const gray = (level: number) => {
|
||||
const factor = level / 12
|
||||
|
||||
if (isDark && lum < 10) {
|
||||
const value = Math.floor(factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
if (!isDark && lum > 245) {
|
||||
const value = Math.floor(255 - factor * 0.4 * 255)
|
||||
return map(RGBA.fromInts(value, value, value))
|
||||
}
|
||||
|
||||
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
|
||||
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
|
||||
if (cast === 0) return map(tone)
|
||||
|
||||
const ratio = lum === 0 ? 0 : value / lum
|
||||
return map(
|
||||
tint(
|
||||
tone,
|
||||
RGBA.fromInts(
|
||||
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
|
||||
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
|
||||
),
|
||||
cast,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
|
||||
}
|
||||
|
||||
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
|
||||
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
|
||||
const gray = isDark
|
||||
? lum < 10
|
||||
? 180
|
||||
: Math.min(Math.floor(160 + lum * 0.3), 200)
|
||||
: lum > 245
|
||||
? 75
|
||||
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
|
||||
|
||||
return map(RGBA.fromInts(gray, gray, gray))
|
||||
}
|
||||
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json {
|
||||
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const bg = RGBA.defaultBackground(bg_snapshot)
|
||||
const fg = RGBA.defaultForeground(fg_snapshot)
|
||||
const isDark = pick === "dark"
|
||||
|
||||
const color = (index: number) => paletteColor(colors, index)
|
||||
|
||||
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
|
||||
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
|
||||
|
||||
const ansi = {
|
||||
red: color(1),
|
||||
green: color(2),
|
||||
yellow: color(3),
|
||||
blue: color(4),
|
||||
magenta: color(5),
|
||||
cyan: color(6),
|
||||
red_bright: color(9),
|
||||
green_bright: color(10),
|
||||
}
|
||||
|
||||
const diff_alpha = isDark ? 0.22 : 0.14
|
||||
const diff_context_bg = grays[2]
|
||||
const primary = ansi.cyan
|
||||
const secondary = ansi.magenta
|
||||
|
||||
return {
|
||||
theme: {
|
||||
primary,
|
||||
secondary,
|
||||
accent: primary,
|
||||
error: ansi.red,
|
||||
warning: ansi.yellow,
|
||||
success: ansi.green,
|
||||
info: ansi.cyan,
|
||||
text: fg,
|
||||
textMuted,
|
||||
selectedListItemText: bg,
|
||||
background: alpha(bg, 0),
|
||||
backgroundPanel: grays[2],
|
||||
backgroundElement: grays[3],
|
||||
backgroundMenu: grays[3],
|
||||
borderSubtle: grays[6],
|
||||
border: grays[7],
|
||||
borderActive: grays[8],
|
||||
diffAdded: ansi.green,
|
||||
diffRemoved: ansi.red,
|
||||
diffContext: grays[7],
|
||||
diffHunkHeader: grays[7],
|
||||
diffHighlightAdded: ansi.green_bright,
|
||||
diffHighlightRemoved: ansi.red_bright,
|
||||
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
|
||||
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
|
||||
diffContextBg: diff_context_bg,
|
||||
diffLineNumber: textMuted,
|
||||
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
|
||||
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
|
||||
markdownText: fg,
|
||||
markdownHeading: fg,
|
||||
markdownLink: ansi.blue,
|
||||
markdownLinkText: ansi.cyan,
|
||||
markdownCode: ansi.green,
|
||||
markdownBlockQuote: ansi.yellow,
|
||||
markdownEmph: ansi.yellow,
|
||||
markdownStrong: fg,
|
||||
markdownHorizontalRule: grays[7],
|
||||
markdownListItem: ansi.blue,
|
||||
markdownListEnumeration: ansi.cyan,
|
||||
markdownImage: ansi.blue,
|
||||
markdownImageText: ansi.cyan,
|
||||
markdownCodeBlock: fg,
|
||||
syntaxComment: textMuted,
|
||||
syntaxKeyword: ansi.magenta,
|
||||
syntaxFunction: ansi.blue,
|
||||
syntaxVariable: fg,
|
||||
syntaxString: ansi.green,
|
||||
syntaxNumber: ansi.yellow,
|
||||
syntaxType: ansi.cyan,
|
||||
syntaxOperator: ansi.cyan,
|
||||
syntaxPunctuation: fg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
|
||||
return RGBA.clone(rgba)
|
||||
}
|
||||
|
||||
return nearestIndexed(indexed, rgba)
|
||||
}
|
||||
|
||||
function quantizeTheme(theme: ThemeCurrent, indexed: RGBA[]): ThemeCurrent {
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme)
|
||||
.filter(([key]) => key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
thinkingOpacity: theme.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function splashTheme(theme: ThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
||||
const left = nearestIndexed(indexed, theme.textMuted)
|
||||
const right = nearestIndexed(indexed, theme.text)
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
||||
}
|
||||
}
|
||||
|
||||
function map(
|
||||
footerTheme: ThemeCurrent,
|
||||
scrollbackTheme: ThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
theme: ResolvedTheme,
|
||||
indexed: RGBA[],
|
||||
mode: "light" | "dark",
|
||||
syntax?: SyntaxStyle,
|
||||
system = false,
|
||||
): RunTheme {
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
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)
|
||||
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.13 : 0.06)
|
||||
const statusAccentBase =
|
||||
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
|
||||
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
|
||||
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
|
||||
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
|
||||
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
|
||||
const elevated = theme.contextual.elevated
|
||||
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
|
||||
const exact = (color: RGBA) => {
|
||||
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
|
||||
if (system && color.equals(theme.background.default)) return RGBA.defaultBackground(color)
|
||||
return color
|
||||
}
|
||||
const scrollback = (color: RGBA) => {
|
||||
const value = exact(color)
|
||||
return value.a === 0 || value.intent !== "rgb" ? value : nearestIndexed(indexed, value)
|
||||
}
|
||||
syntax?.getAllStyles().forEach((style, name) => {
|
||||
syntax.registerStyle(name, {
|
||||
...style,
|
||||
fg: style.fg && scrollback(style.fg),
|
||||
bg: style.bg && scrollback(style.bg),
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
background: footerTheme.background,
|
||||
background: RGBA.defaultBackground(theme.background.default),
|
||||
footer: {
|
||||
highlight: footerTheme.primary,
|
||||
selected: footerTheme.backgroundElement,
|
||||
selectedText: footerTheme.selectedListItemText,
|
||||
warning: footerTheme.warning,
|
||||
error: footerTheme.error,
|
||||
muted: footerTheme.textMuted,
|
||||
text: footerTheme.text,
|
||||
status,
|
||||
statusAccent,
|
||||
shade,
|
||||
surface,
|
||||
pane: footerTheme.backgroundMenu,
|
||||
border: footerTheme.border,
|
||||
line,
|
||||
actionSecondaryText: exact(elevated.text.action.secondary.default),
|
||||
actionFocusedBg: exact(elevated.background.action.primary.focused),
|
||||
actionFocusedText: exact(elevated.text.action.primary.focused),
|
||||
formfieldText: exact(elevated.text.formfield.default),
|
||||
formfieldFocusedBg: exact(elevated.background.formfield.focused),
|
||||
formfieldFocusedText: exact(elevated.text.formfield.focused),
|
||||
selection: exact(elevated.text.formfield.selected),
|
||||
variant: exact(theme.text.subdued),
|
||||
running: exact(theme.text.status.running),
|
||||
question: exact(theme.text.status.question),
|
||||
permission: exact(theme.text.status.permission),
|
||||
success: exact(theme.text.feedback.success.default),
|
||||
link: exact(theme.markdown.link),
|
||||
categorical: dedupeWith(
|
||||
theme.categorical.map((scale) => exact(scale[mode === "light" ? 800 : 200])),
|
||||
(a, b) => a.equals(b),
|
||||
),
|
||||
warning: exact(theme.text.feedback.warning.default),
|
||||
error: exact(theme.text.feedback.error.default),
|
||||
muted: exact(theme.text.subdued),
|
||||
text: exact(theme.text.default),
|
||||
statusAccent: exact(theme.background.surface.overlay),
|
||||
shade: exact(elevated.background.default),
|
||||
surface: exact(elevated.background.default),
|
||||
pane: exact(theme.contextual.overlay.background.default),
|
||||
border: exact(theme.border.default),
|
||||
line: exact(theme.background.surface.overlay),
|
||||
},
|
||||
entry: {
|
||||
system: {
|
||||
body: scrollbackTheme.textMuted,
|
||||
},
|
||||
user: {
|
||||
body: scrollbackTheme.primary,
|
||||
},
|
||||
assistant: {
|
||||
body: scrollbackTheme.text,
|
||||
},
|
||||
reasoning: {
|
||||
body: scrollbackTheme.textMuted,
|
||||
},
|
||||
tool: {
|
||||
body: scrollbackTheme.text,
|
||||
start: scrollbackTheme.textMuted,
|
||||
},
|
||||
error: {
|
||||
body: scrollbackTheme.error,
|
||||
},
|
||||
system: { body: scrollback(theme.text.subdued) },
|
||||
user: { body: scrollback(theme.text.default) },
|
||||
assistant: { body: scrollback(theme.markdown.text) },
|
||||
reasoning: { body: scrollback(theme.text.subdued) },
|
||||
tool: { body: scrollback(theme.text.subdued), start: scrollback(theme.text.subdued) },
|
||||
error: { body: scrollback(theme.text.feedback.error.default) },
|
||||
},
|
||||
splash: {
|
||||
left: nearestIndexed(indexed, theme.text.subdued),
|
||||
right: nearestIndexed(indexed, theme.text.default),
|
||||
leftShadow: nearestIndexed(indexed, theme.background.surface.offset),
|
||||
},
|
||||
splash,
|
||||
block: {
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
text: scrollback(theme.text.default),
|
||||
muted: scrollback(theme.text.subdued),
|
||||
syntax,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
diffRemovedBg: transparent,
|
||||
diffContextBg: transparent,
|
||||
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
|
||||
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
|
||||
diffLineNumber: scrollbackTheme.diffLineNumber,
|
||||
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
|
||||
diffRemoved: scrollback(theme.diff.text.removed),
|
||||
diffAddedBg: scrollback(theme.diff.background.added),
|
||||
diffRemovedBg: scrollback(theme.diff.background.removed),
|
||||
diffContextBg: scrollback(theme.diff.background.context),
|
||||
diffHighlightAdded: scrollback(theme.diff.highlight.added),
|
||||
diffHighlightRemoved: scrollback(theme.diff.highlight.removed),
|
||||
diffLineNumber: scrollback(theme.diff.lineNumber.text),
|
||||
diffAddedLineNumberBg: scrollback(theme.diff.lineNumber.background.added),
|
||||
diffRemovedLineNumberBg: scrollback(theme.diff.lineNumber.background.removed),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
|
||||
muted: RGBA.fromIndex(8, rgba("#64748b")),
|
||||
text: RGBA.defaultForeground(rgba("#f8fafc")),
|
||||
panel: rgba("#0f172a"),
|
||||
success: RGBA.fromIndex(2, rgba("#22c55e")),
|
||||
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
|
||||
error: RGBA.fromIndex(1, rgba("#ef4444")),
|
||||
}
|
||||
|
||||
function tone(body: ColorInput, start?: ColorInput): Tone {
|
||||
return {
|
||||
body,
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
|
||||
const fallbackSplashLeft = RGBA.fromIndex(67)
|
||||
const fallbackSplashRight = RGBA.fromIndex(110)
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
highlight: seed.highlight,
|
||||
selected: seed.text,
|
||||
selectedText: seed.panel,
|
||||
warning: seed.warning,
|
||||
error: seed.error,
|
||||
muted: seed.muted,
|
||||
text: seed.text,
|
||||
status: tint(seed.panel, rgba("#000000"), 0.12),
|
||||
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
|
||||
shade: alpha(seed.panel, 0.68),
|
||||
surface: alpha(seed.panel, 0.86),
|
||||
pane: seed.panel,
|
||||
border: seed.muted,
|
||||
line: alpha(seed.panel, 0.96),
|
||||
},
|
||||
entry: {
|
||||
system: tone(seed.muted),
|
||||
user: tone(seed.highlight),
|
||||
assistant: tone(seed.text),
|
||||
reasoning: tone(seed.muted),
|
||||
tool: tone(seed.text, seed.muted),
|
||||
error: tone(seed.error),
|
||||
},
|
||||
splash: {
|
||||
left: fallbackSplashLeft,
|
||||
right: fallbackSplashRight,
|
||||
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
||||
},
|
||||
block: {
|
||||
text: seed.text,
|
||||
muted: seed.muted,
|
||||
diffRemoved: seed.error,
|
||||
diffAddedBg: alpha(seed.success, 0.18),
|
||||
diffRemovedBg: alpha(seed.error, 0.18),
|
||||
diffContextBg: alpha(seed.panel, 0.72),
|
||||
diffHighlightAdded: seed.success,
|
||||
diffHighlightRemoved: seed.error,
|
||||
diffLineNumber: seed.muted,
|
||||
diffAddedLineNumberBg: alpha(seed.success, 0.12),
|
||||
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
|
||||
},
|
||||
}
|
||||
export const RUN_THEME_FALLBACK = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
|
||||
ansiPalette,
|
||||
"dark",
|
||||
)
|
||||
export const RUN_THEME_FALLBACK_LIGHT = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
|
||||
ansiPalette,
|
||||
"light",
|
||||
)
|
||||
|
||||
function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
const foreground = RGBA.defaultForeground(mode === "light" ? "#000000" : "#ffffff")
|
||||
@@ -513,14 +214,24 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
return {
|
||||
background,
|
||||
footer: {
|
||||
highlight: foreground,
|
||||
selected: background,
|
||||
selectedText: foreground,
|
||||
actionSecondaryText: foreground,
|
||||
actionFocusedBg: background,
|
||||
actionFocusedText: foreground,
|
||||
formfieldText: foreground,
|
||||
formfieldFocusedBg: background,
|
||||
formfieldFocusedText: foreground,
|
||||
selection: foreground,
|
||||
variant: foreground,
|
||||
running: foreground,
|
||||
question: foreground,
|
||||
permission: foreground,
|
||||
success: foreground,
|
||||
link: foreground,
|
||||
categorical: [foreground],
|
||||
warning: foreground,
|
||||
error: foreground,
|
||||
muted: foreground,
|
||||
text: foreground,
|
||||
status: background,
|
||||
statusAccent: background,
|
||||
shade: background,
|
||||
surface: background,
|
||||
@@ -529,18 +240,14 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
line: background,
|
||||
},
|
||||
entry: {
|
||||
system: tone(foreground),
|
||||
user: tone(foreground),
|
||||
assistant: tone(foreground),
|
||||
reasoning: tone(foreground),
|
||||
tool: tone(foreground),
|
||||
error: tone(foreground),
|
||||
},
|
||||
splash: {
|
||||
left: foreground,
|
||||
right: foreground,
|
||||
leftShadow: background,
|
||||
system: { body: foreground },
|
||||
user: { body: foreground },
|
||||
assistant: { body: foreground },
|
||||
reasoning: { body: foreground },
|
||||
tool: { body: foreground },
|
||||
error: { body: foreground },
|
||||
},
|
||||
splash: { left: foreground, right: foreground, leftShadow: background },
|
||||
block: {
|
||||
text: foreground,
|
||||
muted: foreground,
|
||||
@@ -566,37 +273,55 @@ export async function resolveRunTheme(
|
||||
mono = false,
|
||||
): Promise<RunTheme> {
|
||||
if (mono) {
|
||||
const mode =
|
||||
config?.mode === "light" || config?.mode === "dark"
|
||||
? config.mode
|
||||
: (renderer.themeMode ?? (await renderer.waitForThemeMode(300)))
|
||||
const mode = renderer.themeMode ?? (await renderer.waitForThemeMode(300)) ?? config?.mode
|
||||
return mode === "light" ? RUN_THEME_MONO_LIGHT : RUN_THEME_MONO
|
||||
}
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 256,
|
||||
})
|
||||
const bg = colors.defaultBackground ?? colors.palette[0]
|
||||
if (!bg) {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
// Palette-only terminal reloads can leave renderer.themeMode stale, but
|
||||
// ANSI slot zero is not the terminal background when OSC 11 is absent.
|
||||
const pick =
|
||||
config?.mode === "dark" || config?.mode === "light"
|
||||
? config.mode
|
||||
: (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)
|
||||
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
|
||||
const syntaxTheme: SharedSyntaxTheme = {
|
||||
...scrollbackTheme,
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
const detected = await renderer.getPalette({ size: 256 }).catch(() => undefined)
|
||||
// A transient OSC timeout must not remap immutable scrollback against an unrelated ANSI palette.
|
||||
const colors =
|
||||
(detected?.defaultBackground ?? detected?.palette[0]) && (detected?.defaultForeground ?? detected?.palette[7])
|
||||
? detected
|
||||
: palettes.get(renderer)
|
||||
if (colors) palettes.set(renderer, colors)
|
||||
// Mini stays transparent: fresh OSC 11 wins, but cached colors must not override a new renderer mode.
|
||||
const mode =
|
||||
(detected && terminalMode(detected)) ??
|
||||
renderer.themeMode ??
|
||||
(colors && terminalMode(colors)) ??
|
||||
(config?.mode === "light" ? "light" : "dark")
|
||||
const name = config?.name ?? "opencode"
|
||||
if (name === "system" && !colors) {
|
||||
return mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
const resolved = await themeSource(name, colors, mode)
|
||||
.then((source) => {
|
||||
const document = parseTheme(source, name)
|
||||
// Mini keeps the terminal background, so the opposite theme mode is not a safe fallback.
|
||||
if (themeModes(document).includes(mode)) return resolveThemeDocument(document, mode)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const theme = resolved ?? resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
const indexed = colors
|
||||
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
|
||||
: ansiPalette
|
||||
return {
|
||||
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
|
||||
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
|
||||
}
|
||||
}
|
||||
|
||||
async function themeSource(
|
||||
name: string,
|
||||
colors: TerminalColors | undefined,
|
||||
mode: "dark" | "light",
|
||||
): Promise<ThemeDocumentSource> {
|
||||
if (name === "system" && colors) return generateSystem(colors, mode)
|
||||
const { Global } = await import("@opencode-ai/util/global")
|
||||
const custom = await discoverThemes(
|
||||
configDirectories(process.env.OPENCODE_CONFIG_DIR ?? Global.Path.config, process.cwd()),
|
||||
)
|
||||
const source = custom[name] ?? allThemes()[name] ?? DEFAULT_THEMES.opencode
|
||||
return isThemeSource(source) ? source : DEFAULT_THEMES.opencode
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type RunPromptPart =
|
||||
url: string
|
||||
filename?: string
|
||||
mime?: string
|
||||
description?: string
|
||||
source?: {
|
||||
type: string
|
||||
text: { start: number; end: number; value: string }
|
||||
@@ -119,6 +120,7 @@ export type RunInput = {
|
||||
}
|
||||
|
||||
export type MiniHost = {
|
||||
version: string
|
||||
terminal: {
|
||||
stdin: NodeJS.ReadStream
|
||||
}
|
||||
@@ -176,7 +178,7 @@ export type FooterState = {
|
||||
status: string
|
||||
notice: string
|
||||
model: string
|
||||
usage: string
|
||||
usage: { tokens: number; percent?: number; cost?: number } | undefined
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
@@ -393,7 +395,10 @@ export type FormCancel = {
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session" | "cursor">
|
||||
export type RunTuiConfig = Pick<
|
||||
Config.Resolved,
|
||||
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations"
|
||||
>
|
||||
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
@@ -401,6 +406,7 @@ export type MiniSettings = {
|
||||
turn_summary: "show" | "hide"
|
||||
footer: "show" | "hide"
|
||||
splash: "show" | "hide"
|
||||
work_spinner: Config.MiniWorkSpinner
|
||||
mono: boolean
|
||||
}
|
||||
|
||||
@@ -423,6 +429,7 @@ type StreamToolState = "running" | "completed" | "error"
|
||||
export type StreamCommit = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
image?: string
|
||||
phase: StreamPhase
|
||||
source: StreamSource
|
||||
compaction?: true
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { octantGlyph } from "./subcell"
|
||||
import type { Config } from "../config"
|
||||
|
||||
export type OneCellMotion = {
|
||||
frames: string[]
|
||||
interval: number
|
||||
levels?: number[]
|
||||
intro?: Pick<OneCellMotion, "frames" | "interval" | "levels">
|
||||
once?: boolean
|
||||
pace?: { initial: number; final: number; after: number; duration: number }
|
||||
}
|
||||
|
||||
export function oneCellFrame(animation: OneCellMotion, elapsed: number) {
|
||||
const introDuration = animation.intro ? animation.intro.frames.length * animation.intro.interval : 0
|
||||
const intro = elapsed < introDuration
|
||||
const clip = intro ? animation.intro! : animation
|
||||
const pace = animation.pace
|
||||
const progress = pace ? Math.max(0, Math.min(1, (elapsed - pace.after) / pace.duration)) : 0
|
||||
// Integrate the changing rate so slowing down does not jump or restart the motion.
|
||||
const time = intro
|
||||
? elapsed
|
||||
: !pace
|
||||
? elapsed - introDuration
|
||||
: (elapsed - introDuration) * pace.initial +
|
||||
(pace.final - pace.initial) *
|
||||
(pace.duration * (progress ** 3 - progress ** 4 / 2) + Math.max(0, elapsed - pace.after - pace.duration))
|
||||
// Keep cycle boundaries exact when individual frame intervals are fractional.
|
||||
const frame = Math.floor((time * clip.frames.length) / (clip.interval * clip.frames.length))
|
||||
const index = !intro && animation.once ? Math.min(frame, clip.frames.length - 1) : frame % clip.frames.length
|
||||
return {
|
||||
glyph: clip.frames[index]!,
|
||||
level: clip.levels?.[index] ?? 1,
|
||||
complete: !intro && !!animation.once && frame >= clip.frames.length - 1,
|
||||
}
|
||||
}
|
||||
|
||||
export const SEED_WORK: OneCellMotion = {
|
||||
frames: Array.from({ length: 40 }, () => "\u25aa"),
|
||||
interval: 40,
|
||||
levels: Array.from({ length: 40 }, (_, index) => 0.3 + (0.7 * (1 - Math.cos((index / 40) * 2 * Math.PI))) / 2),
|
||||
intro: {
|
||||
frames: Array.from({ length: 80 }, () => "\u25aa"),
|
||||
interval: 40,
|
||||
levels: Array.from({ length: 80 }, (_, index) => {
|
||||
const phase = index / 80
|
||||
return 0.3 + 0.7 * (phase < 0.125 ? phase / 0.125 : phase < 0.375 ? 1 : ((1 - phase) / 0.625) ** 3)
|
||||
}),
|
||||
},
|
||||
pace: { initial: 1.25, final: 0.5, after: 30_000, duration: 30_000 },
|
||||
}
|
||||
|
||||
const WORK_PACE = { initial: 1.2, final: 0.96, after: 30_000, duration: 30_000 }
|
||||
const lower = [2, 3, 5, 7, 6, 4]
|
||||
|
||||
export const BLOCK_LOW_COMET: OneCellMotion = {
|
||||
frames: lower.flatMap((point, index, path) => {
|
||||
const leading = (1 << point) | (1 << path[(index + 5) % 6]!)
|
||||
const full = leading | (1 << path[(index + 4) % 6]!)
|
||||
return [full, full, full, leading, leading].map(octantGlyph)
|
||||
}),
|
||||
interval: 40,
|
||||
pace: WORK_PACE,
|
||||
}
|
||||
|
||||
export const BLOCK_SOFT_SWEEP: OneCellMotion = {
|
||||
frames: [0x14, 0x14, 0x14, 0x1c, 0x3c, 0x38, 0x28, 0x28, 0x28, 0x38, 0x3c, 0x1c].map(octantGlyph),
|
||||
interval: 100,
|
||||
pace: WORK_PACE,
|
||||
}
|
||||
|
||||
export const BLOCK_SOFT_SLIDE: OneCellMotion = {
|
||||
frames: [0x14, 0x14, 0x14, 0x14, 0x14, 0x28, 0x28, 0x28, 0x28, 0x28].map(octantGlyph),
|
||||
interval: 100,
|
||||
levels: [0.55, 0.85, 1, 0.85, 0.55, 0.55, 0.85, 1, 0.85, 0.55],
|
||||
}
|
||||
|
||||
export const WORK_SPINNERS = {
|
||||
"block-soft-slide": BLOCK_SOFT_SLIDE,
|
||||
"block-soft-sweep": BLOCK_SOFT_SWEEP,
|
||||
"block-low-comet": BLOCK_LOW_COMET,
|
||||
"block-low-duet": {
|
||||
frames: lower.slice(0, 3).flatMap((point, index) => {
|
||||
const heads = (1 << point) | (1 << lower[(index + 3) % 6]!)
|
||||
const full = heads | (1 << lower[(index + 2) % 6]!) | (1 << lower[(index + 5) % 6]!)
|
||||
return [full, full, full, heads].map(octantGlyph)
|
||||
}),
|
||||
interval: 60,
|
||||
},
|
||||
"block-shuttle": { frames: [0x14, 0x28].map(octantGlyph), interval: 500 },
|
||||
"block-bridge": { frames: [0x14, 0x14, 0x14, 0x3c, 0x28, 0x28, 0x28, 0x3c].map(octantGlyph), interval: 120 },
|
||||
"block-squeeze": {
|
||||
frames: [0x14, 0x14, 0x14, 0x10, 0x20, 0x28, 0x28, 0x28, 0x20, 0x10].map(octantGlyph),
|
||||
interval: 100,
|
||||
},
|
||||
"small-toggle": { frames: Array.from("\u25ab\u25aa"), interval: 320 },
|
||||
"square-toggle": { frames: Array.from("\u25a1\u25a0"), interval: 500 },
|
||||
"grow-shrink": { frames: Array.from("\u25ab\u25ab\u25aa\u25a0\u25aa"), interval: 180 },
|
||||
"quadrant-orbit": { frames: Array.from("\u2598\u259d\u2597\u2596"), interval: 160 },
|
||||
crosshatch: { frames: Array.from("\u25a7\u25a9\u25a8\u25a9"), interval: 240 },
|
||||
"density-wave": { frames: Array.from("\u2591\u2591\u2591\u2592\u2593\u2588\u2593\u2592"), interval: 160 },
|
||||
seed: SEED_WORK,
|
||||
} satisfies Record<Config.MiniWorkSpinner, OneCellMotion>
|
||||
|
||||
export const SEED_LAUNCH: OneCellMotion = {
|
||||
frames: Array.from({ length: 21 }, (_, index) => (index < 10 ? "\u25ab" : "\u25aa")),
|
||||
interval: 40,
|
||||
levels: SEED_WORK.levels!.slice(0, 21),
|
||||
once: true,
|
||||
}
|
||||
|
||||
export const SEED_MONO: OneCellMotion = { frames: ["-", "\\", "|", "/"], interval: 120 }
|
||||
@@ -0,0 +1,37 @@
|
||||
// Masks use row-major bits in a 2x4 grid. Unicode reuses these older glyphs
|
||||
// instead of duplicating them in the otherwise mask-ordered octant block.
|
||||
const OCTANTS = new Map<number, string>([
|
||||
[0x00, " "],
|
||||
[0x01, "\u{1cea8}"],
|
||||
[0x02, "\u{1ceab}"],
|
||||
[0x03, "\u{1fb82}"],
|
||||
[0x05, "\u2598"],
|
||||
[0x0a, "\u259d"],
|
||||
[0x0f, "\u2580"],
|
||||
[0x14, "\u{1fbe6}"],
|
||||
[0x28, "\u{1fbe7}"],
|
||||
[0x3f, "\u{1fb85}"],
|
||||
[0x40, "\u{1cea3}"],
|
||||
[0x50, "\u2596"],
|
||||
[0x55, "\u258c"],
|
||||
[0x5a, "\u259e"],
|
||||
[0x5f, "\u259b"],
|
||||
[0x80, "\u{1cea0}"],
|
||||
[0xa0, "\u2597"],
|
||||
[0xa5, "\u259a"],
|
||||
[0xaa, "\u2590"],
|
||||
[0xaf, "\u259c"],
|
||||
[0xc0, "\u2582"],
|
||||
[0xf0, "\u2584"],
|
||||
[0xf5, "\u2599"],
|
||||
[0xfa, "\u259f"],
|
||||
[0xfc, "\u2586"],
|
||||
[0xff, "\u2588"],
|
||||
])
|
||||
|
||||
export function octantGlyph(mask: number) {
|
||||
return (
|
||||
OCTANTS.get(mask) ??
|
||||
String.fromCodePoint(0x1cd00 + mask - [...OCTANTS.keys()].filter((value) => value < mask).length)
|
||||
)
|
||||
}
|
||||
@@ -17,12 +17,16 @@ test("validates the three explicit diff source defaults", () => {
|
||||
expect(() => decodeInfo({ diffs: { source: "auto" } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates mini replay settings", () => {
|
||||
test("validates mini replay and work spinner settings", () => {
|
||||
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
|
||||
mini: { replay: false, replay_limit: 50 },
|
||||
})
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({
|
||||
mini: { work_spinner: "quadrant-orbit" },
|
||||
})
|
||||
expect(() => decodeInfo({ mini: { work_spinner: "unknown" } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates the session tabs setting", () => {
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RunFooter } from "../../src/mini/footer"
|
||||
import { RunFormBody } from "../../src/mini/footer.form"
|
||||
import { RunPermissionBody } from "../../src/mini/footer.permission"
|
||||
import { RunFooterSubagentBody } from "../../src/mini/footer.subagent"
|
||||
import { createFormBodyState } from "../../src/mini/form.shared"
|
||||
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
|
||||
import type { FormReply, MiniFormRequest, MiniPermissionRequest, PermissionReply } from "../../src/mini/types"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
const permission: MiniPermissionRequest = {
|
||||
id: "per_responsive",
|
||||
sessionID: "ses_responsive",
|
||||
action: "shell",
|
||||
resources: ["rm -rf /project/build/cache"],
|
||||
save: ["/project/build/*", "/project/private/GRANT_SENTINEL"],
|
||||
}
|
||||
const form: MiniFormRequest = {
|
||||
id: "frm_responsive",
|
||||
sessionID: "ses_responsive",
|
||||
title: "Deployment",
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
required: true,
|
||||
options: Array.from({ length: 12 }, (_, index) => ({
|
||||
value: `target-${index + 1}`,
|
||||
label: `Target ${index + 1}`,
|
||||
description: `Environment ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function descendants(root: Renderable): Renderable[] {
|
||||
return root.getChildren().flatMap((child) => [child, ...descendants(child)])
|
||||
}
|
||||
|
||||
async function settle(app: Pick<Awaited<ReturnType<typeof testRender>>, "renderOnce">) {
|
||||
await app.renderOnce()
|
||||
await Bun.sleep(0)
|
||||
await app.renderOnce()
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
async function renderForm(request: MiniFormRequest, width = 80, height = 20) {
|
||||
const replies: FormReply[] = []
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunFormBody
|
||||
request={request}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
onReply={(reply) => {
|
||||
replies.push(reply)
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width, height, kittyKeyboard: true },
|
||||
)
|
||||
return { ...app, replies }
|
||||
}
|
||||
|
||||
test("roomy question forms retain the established spacing and inline descriptions", async () => {
|
||||
const app = await renderForm({
|
||||
...form,
|
||||
title: "Questions",
|
||||
fields: [
|
||||
{
|
||||
key: "layout",
|
||||
type: "string",
|
||||
title: "Layout",
|
||||
description: "Which footer view?",
|
||||
options: [
|
||||
{ value: "form", label: "Form", description: "Inspect canonical form" },
|
||||
{ value: "prompt", label: "Prompt", description: "Return to composer" },
|
||||
],
|
||||
},
|
||||
{ key: "extra", type: "boolean", title: "Extra checks" },
|
||||
],
|
||||
})
|
||||
try {
|
||||
await settle(app)
|
||||
for (const [text, x, y] of [
|
||||
["Questions", 4, 1],
|
||||
["1/2", 14, 1],
|
||||
["Which footer view?", 2, 3],
|
||||
["Form", 5, 5],
|
||||
["Inspect canonical form", 10, 5],
|
||||
] as const) {
|
||||
const node = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === text,
|
||||
)!
|
||||
expect({ text, x: node.x, y: node.y }).toEqual({ text, x, y })
|
||||
}
|
||||
expect(app.captureCharFrame().split("\n")[18]).toContain("↑↓ select enter choose tab next esc dismiss")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([16, 24, 40])(
|
||||
"complete questions and distinct option details remain readable at %i columns",
|
||||
async (width) => {
|
||||
const content = (app: Awaited<ReturnType<typeof renderForm>>) =>
|
||||
app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((row) => row.slice(0, width - 1))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
const title = "Delete production database permanently?"
|
||||
const app = await renderForm({ ...form, fields: [{ key: "yes", type: "boolean", title }] }, width, 8)
|
||||
try {
|
||||
await settle(app)
|
||||
app.mockInput.pressKey("\x1b[5~")
|
||||
await settle(app)
|
||||
expect(content(app)).toContain(title)
|
||||
expect(app.replies).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
const options = await renderForm(
|
||||
{
|
||||
...form,
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
options: [
|
||||
{ value: "stage", label: "Deploy api service to staging", description: "STAGING database" },
|
||||
{ value: "prod", label: "Deploy api service to production", description: "PRODUCTION database" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
width,
|
||||
12,
|
||||
)
|
||||
try {
|
||||
await settle(options)
|
||||
expect(content(options)).toContain("Deploy api service to staging")
|
||||
expect(content(options)).toContain("STAGING database")
|
||||
options.mockInput.pressArrow("down")
|
||||
await settle(options)
|
||||
expect(content(options)).toContain("Deploy api service to production")
|
||||
expect(content(options)).toContain("PRODUCTION database")
|
||||
options.mockInput.pressEnter()
|
||||
await settle(options)
|
||||
expect(options.replies[0]?.answer).toEqual({ target: "prod" })
|
||||
} finally {
|
||||
options.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("advancing fields reveals the new choice without a second Enter", async () => {
|
||||
const app = await renderForm(
|
||||
{
|
||||
...form,
|
||||
fields: [
|
||||
{ key: "confirm", type: "boolean", title: "Continue?" },
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
description: "Select your preferred destination for this deployment and double-check it carefully.",
|
||||
options: [
|
||||
{ value: "b0", label: "B0" },
|
||||
{ value: "b1", label: "B1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
16,
|
||||
8,
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("B0")
|
||||
expect(app.replies).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Review")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("roomy permission prompts retain their header and padded action bar", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunPermissionBody
|
||||
request={permission}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
block={RUN_THEME_FALLBACK.block}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width: 112, height: 20 },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
const nodes = descendants(app.renderer.root).filter(
|
||||
(node): node is TextRenderable => node instanceof TextRenderable,
|
||||
)
|
||||
const title = nodes.find((node) => node.plainText.includes("Permission required"))!
|
||||
const action = nodes.find((node) => node.plainText === "Allow once")!
|
||||
expect({ x: title.x, y: title.y }).toEqual({ x: 2, y: 1 })
|
||||
expect({ x: action.x, y: action.y }).toEqual({ x: 3, y: 18 })
|
||||
expect(app.captureCharFrame().split("\n")[18]).toContain("⇆ select enter confirm esc reject")
|
||||
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
|
||||
expect(scroll.x + scroll.width).toBe(109)
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Waiting for permission event...")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("roomy rejection keeps the editor and confirmation hints inline", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<Keymap.Provider config={createTuiResolvedConfig()}>
|
||||
<RunPermissionBody
|
||||
request={permission}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
block={RUN_THEME_FALLBACK.block}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
),
|
||||
{ width: 112, height: 20, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
app.mockInput.pressArrow("right")
|
||||
app.mockInput.pressArrow("right")
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
const editor = app.renderer.currentFocusedEditor!
|
||||
const confirm = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === "enter confirm",
|
||||
)!
|
||||
expect(editor.y).toBe(18)
|
||||
expect(confirm.y).toBe(editor.y)
|
||||
expect(confirm.x).toBeGreaterThanOrEqual(editor.x + editor.width)
|
||||
expect(app.captureCharFrame()).toContain("esc cancel")
|
||||
expect(app.captureCharFrame()).toContain("Tell OpenCode what to do differently")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("picked options budget their full ordinal and selection marker (mono=%s)", async (mono) => {
|
||||
for (const width of [16, 112]) {
|
||||
const description = width === 16 ? "prod" : "d".repeat(85) + "_END_LAST"
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunFormBody
|
||||
request={{
|
||||
...form,
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
default: "target-12",
|
||||
options: Array.from({ length: 12 }, (_, index) => ({
|
||||
value: `target-${index + 1}`,
|
||||
label: "Deploy",
|
||||
description,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
mono={mono}
|
||||
onReply={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width, height: 20, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
const label = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === "Deploy *",
|
||||
)!
|
||||
const detail = label
|
||||
.parent!.getChildren()
|
||||
.find((node) => node instanceof TextRenderable && node.plainText === description)!
|
||||
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
|
||||
expect(detail.y).toBeGreaterThan(label.y)
|
||||
expect(detail.x + detail.width).toBeLessThanOrEqual(scroll.viewport.x + scroll.viewport.width - (mono ? 0 : 1))
|
||||
expect(app.captureCharFrame()).toContain(description)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ width: 16, height: 8 },
|
||||
{ width: 24, height: 8 },
|
||||
{ width: 32, height: 8 },
|
||||
{ width: 40, height: 8 },
|
||||
{ width: 56, height: 8 },
|
||||
{ width: 56, height: 12 },
|
||||
{ width: 80, height: 12 },
|
||||
{ width: 112, height: 20 },
|
||||
])("permission controls fit the allocated $width x $height viewport", async (size) => {
|
||||
const replies: PermissionReply[] = []
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunPermissionBody
|
||||
request={permission}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
block={RUN_THEME_FALLBACK.block}
|
||||
onReply={(reply) => {
|
||||
replies.push(reply)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ ...size, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
for (const label of ["Allow once", "Always allow", "Reject"]) {
|
||||
expect(app.captureCharFrame()).toContain(label)
|
||||
const node = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === label,
|
||||
)!
|
||||
expect(node.x + node.width).toBeLessThanOrEqual(size.width)
|
||||
expect(node.y + node.height).toBeLessThanOrEqual(size.height)
|
||||
}
|
||||
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
|
||||
expect(scroll.height).toBeGreaterThan(0)
|
||||
expect(replies).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["once", "always", "reject"] as const)(
|
||||
"24 x 8 production permission confirms the visible %s choice",
|
||||
async (choice) => {
|
||||
const app = await createTestRenderer({
|
||||
width: 24,
|
||||
height: 8,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
const replies: PermissionReply[] = []
|
||||
const footer = new RunFooter(app.renderer, {
|
||||
directory: () => "/project",
|
||||
findFiles: async () => [],
|
||||
agents: [],
|
||||
references: [],
|
||||
agent: undefined,
|
||||
modelLabel: "GPT-5",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
first: false,
|
||||
theme: RUN_THEME_FALLBACK,
|
||||
tuiConfig: createTuiResolvedConfig(),
|
||||
miniSettings: {
|
||||
current: {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "hide",
|
||||
footer: "show",
|
||||
splash: "hide",
|
||||
work_spinner: "block-soft-slide",
|
||||
mono: false,
|
||||
},
|
||||
},
|
||||
onPermissionReply: (reply) => {
|
||||
replies.push(reply)
|
||||
},
|
||||
onFormReply: () => {},
|
||||
onFormCancel: () => {},
|
||||
onEditorOpen: async () => undefined,
|
||||
subscribeThemeSignal: () => () => {},
|
||||
})
|
||||
try {
|
||||
await settle(app)
|
||||
footer.event({ type: "stream.view", view: { type: "permission", request: permission } })
|
||||
await settle(app)
|
||||
for (const label of ["Allow once", "Always allow", "Reject"]) expect(app.captureCharFrame()).toContain(label)
|
||||
for (let index = 0; index < ["once", "always", "reject"].indexOf(choice); index++) app.mockInput.pressTab()
|
||||
await settle(app)
|
||||
const label = choice === "once" ? "Allow once" : choice === "always" ? "Always allow" : "Reject"
|
||||
const node = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === label,
|
||||
)!
|
||||
expect((node.parent as BoxRenderable).backgroundColor.toInts()).toEqual(
|
||||
(RUN_THEME_FALLBACK.footer.actionFocusedBg as RGBA).toInts(),
|
||||
)
|
||||
expect(replies).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
if (choice !== "once") {
|
||||
expect(replies).toEqual([])
|
||||
expect(app.captureCharFrame()).toContain(choice === "always" ? "Confirm" : "enter reject")
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
}
|
||||
expect(replies).toEqual([{ sessionID: permission.sessionID, requestID: permission.id, reply: choice }])
|
||||
} finally {
|
||||
footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
{ width: 16, height: 8 },
|
||||
{ width: 24, height: 8 },
|
||||
{ width: 32, height: 12 },
|
||||
{ width: 56, height: 8 },
|
||||
{ width: 56, height: 12 },
|
||||
{ width: 80, height: 20 },
|
||||
])("form choices reveal selection at $width x $height", async (size) => {
|
||||
const app = await renderForm(form, size.width, size.height)
|
||||
try {
|
||||
await settle(app)
|
||||
for (let index = 0; index < 12; index++) {
|
||||
if (index > 0) app.mockInput.pressKey("ARROW_DOWN")
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain(`Target ${index + 1}`)
|
||||
const selected = descendants(app.renderer.root).find(
|
||||
(node) => node instanceof TextRenderable && node.plainText === `Target ${index + 1}`,
|
||||
)!
|
||||
const scroll = descendants(app.renderer.root).find(
|
||||
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
|
||||
)!
|
||||
expect(selected.y).toBeGreaterThanOrEqual(scroll.viewport.y)
|
||||
expect(selected.y + selected.height).toBeLessThanOrEqual(scroll.viewport.y + scroll.viewport.height)
|
||||
}
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("form review scrolls from the first answer to the last", async () => {
|
||||
const request: MiniFormRequest = {
|
||||
...form,
|
||||
fields: [
|
||||
{ key: "field-1", type: "boolean", title: "Field 1", default: true },
|
||||
...Array.from({ length: 11 }, (_, index) => ({
|
||||
key: `field-${index + 2}`,
|
||||
type: "boolean" as const,
|
||||
title: `Field ${index + 2}`,
|
||||
default: index % 2 !== 0,
|
||||
})),
|
||||
],
|
||||
}
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunFormBody
|
||||
request={request}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
state={{ ...createFormBodyState(request), field: 12 }}
|
||||
onReply={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width: 24, height: 8, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Field 1: Yes")
|
||||
expect(app.captureCharFrame()).not.toContain("Field 12: No")
|
||||
app.mockInput.pressKey("\x1b[6~")
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Field 12: No")
|
||||
expect(app.captureCharFrame()).toContain("enter submit")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("long permission paths, diffs and persistent scopes remain keyboard accessible", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunPermissionBody
|
||||
request={{
|
||||
...permission,
|
||||
action: "edit",
|
||||
save: [...Array.from({ length: 8 }, (_, index) => `/project/resource-${index}/*`), ...permission.save!],
|
||||
resources: ["/project/long/path/to/sensitive/configuration/auth.ts"],
|
||||
metadata: {
|
||||
diff: "--- a/auth.ts\n+++ b/auth.ts\n@@ -10000,2 +10000,2 @@\n-const allow = false\n+DIFF_SENTINEL\n return allow\n",
|
||||
},
|
||||
}}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
block={RUN_THEME_FALLBACK.block}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width: 24, height: 8 },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
const frames = [app.captureCharFrame()]
|
||||
for (let index = 0; index < 12; index++) {
|
||||
app.mockInput.pressKey("\x1b[6~")
|
||||
await settle(app)
|
||||
frames.push(app.captureCharFrame())
|
||||
}
|
||||
expect(frames.join("\n")).toContain("+DIFF_SENTINEL")
|
||||
const scroll = descendants(app.renderer.root).find(
|
||||
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
|
||||
)!
|
||||
const top = scroll.scrollTop
|
||||
expect(top).toBeGreaterThan(0)
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await settle(app)
|
||||
expect(scroll.scrollTop).toBe(top)
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
const scopes = [app.captureCharFrame()]
|
||||
for (let index = 0; index < 12; index++) {
|
||||
app.mockInput.pressKey("\x1b[6~")
|
||||
await settle(app)
|
||||
scopes.push(app.captureCharFrame())
|
||||
}
|
||||
expect(scopes.join("\n")).toContain("GRANT_SENTINEL")
|
||||
expect(app.captureCharFrame()).toContain("Confirm")
|
||||
expect(app.captureCharFrame()).toContain("Cancel")
|
||||
const end = scroll.scrollTop
|
||||
app.mockInput.pressKey("\x1b[5~")
|
||||
await settle(app)
|
||||
expect(scroll.scrollTop).toBeLessThan(end)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("scrolling a choice offscreen reveals it before submission and survives resize", async () => {
|
||||
const app = await renderForm(form, 24, 8)
|
||||
try {
|
||||
await settle(app)
|
||||
app.mockInput.pressKey("\x1b[6~")
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).not.toMatch(/^1\. Target 1(?: |$)/m)
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.replies).toEqual([])
|
||||
expect(app.captureCharFrame()).toMatch(/^1\. Target 1(?: |$)/m)
|
||||
for (let index = 0; index < 11; index++) app.mockInput.pressKey("ARROW_DOWN")
|
||||
await settle(app)
|
||||
for (const size of [
|
||||
{ width: 80, height: 20 },
|
||||
{ width: 16, height: 8 },
|
||||
{ width: 24, height: 8 },
|
||||
]) {
|
||||
app.resize(size.width, size.height)
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Target 12")
|
||||
}
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([16, 24, 80])("text form keeps its editor, error and controls at %s x 8", async (width) => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<Keymap.Provider config={createTuiResolvedConfig()}>
|
||||
<RunFormBody
|
||||
request={{
|
||||
...form,
|
||||
fields: [
|
||||
{
|
||||
key: "service",
|
||||
type: "string",
|
||||
title: "Service",
|
||||
required: true,
|
||||
description:
|
||||
"Choose the service name for deployment. This name will be used to create production resources and route incoming requests to the correct application.",
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
onReply={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
),
|
||||
{ width, height: 8, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Answer required")
|
||||
expect(app.captureCharFrame()).toContain("enter save")
|
||||
expect(app.captureCharFrame()).toContain("esc dismiss")
|
||||
const editor = app.renderer.currentFocusedEditor!
|
||||
expect(editor.y).toBeGreaterThan(1)
|
||||
expect(editor.y + editor.height).toBeLessThan(8)
|
||||
await app.mockInput.typeText("api")
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("api")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("external form exposes its complete URL and state-specific actions", async () => {
|
||||
const opened: string[] = []
|
||||
const url = "https://identity.example.test/oauth/authorize?client_id=opencode&redirect_uri=URL_SENTINEL"
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunFormBody
|
||||
request={{ ...form, fields: [{ key: "auth", type: "external", title: "Sign in", url }] }}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
openExternal={async (value) => {
|
||||
opened.push(value)
|
||||
}}
|
||||
onReply={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width: 24, height: 8, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("enter open URL")
|
||||
expect(app.captureCharFrame()).not.toContain("choose")
|
||||
for (let index = 0; index < 10; index++) app.mockInput.pressKey("\x1b[6~")
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("URL_SENTINEL")
|
||||
app.mockInput.pressEnter()
|
||||
await settle(app)
|
||||
expect(opened).toEqual([url])
|
||||
expect(app.captureCharFrame()).toContain("enter acknowledge")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("wrapped permission characters stay outside the scrollbar (mono=%s)", async (mono) => {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunPermissionBody
|
||||
request={{ ...permission, resources: [alphabet] }}
|
||||
theme={theme.footer}
|
||||
block={theme.block}
|
||||
mono={mono}
|
||||
onReply={() => {}}
|
||||
/>
|
||||
),
|
||||
{ width: 24, height: 20 },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
const scroll = descendants(app.renderer.root).find(
|
||||
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
|
||||
)!
|
||||
const rows = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.slice(scroll.viewport.y, scroll.viewport.y + scroll.viewport.height)
|
||||
expect(rows.map((row) => row.slice(0, mono ? 24 : 23).trim()).join("")).toContain(alphabet)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ width: 16, height: 8 },
|
||||
{ width: 56, height: 8 },
|
||||
{ width: 56, height: 12 },
|
||||
{ width: 112, height: 20 },
|
||||
])("inspector keeps task identity and controls at $width x $height", async (size) => {
|
||||
let closed = 0
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<RunFooterSubagentBody
|
||||
active={() => true}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tab={() => ({ sessionID: "child", label: "Explore", description: "Inspect authentication", status: "running" })}
|
||||
index={() => 12}
|
||||
total={() => 12}
|
||||
detail={() => ({ commits: [{ kind: "system", source: "system", phase: "final", text: "Activity" }] })}
|
||||
interrupt={() => "ctrl+d"}
|
||||
onCycle={() => {}}
|
||||
onClose={() => {
|
||||
closed++
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ ...size, kittyKeyboard: true },
|
||||
)
|
||||
try {
|
||||
await settle(app)
|
||||
expect(app.captureCharFrame()).toContain("Inspect")
|
||||
if (size.width < 56 || size.height < 12) expect(app.captureCharFrame()).toContain("esc back")
|
||||
else expect(app.captureCharFrame()).toContain("12 of 12")
|
||||
expect(app.captureCharFrame()).toContain("ctrl+d interrupt")
|
||||
expect(app.captureCharFrame()).toContain("Activity")
|
||||
app.mockInput.pressEscape()
|
||||
await settle(app)
|
||||
expect(closed).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -56,9 +56,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
submit(text: string | RunPrompt, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
const prompt: RunPrompt =
|
||||
typeof text === "string"
|
||||
? { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
: text
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
|
||||
status: "",
|
||||
notice: "",
|
||||
model: "gpt-5",
|
||||
usage: "",
|
||||
usage: undefined,
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
@@ -57,7 +57,6 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
|
||||
providers={() => undefined}
|
||||
currentAgent={() => "Build"}
|
||||
currentAgentID={() => "build"}
|
||||
currentAgentExplicit={() => false}
|
||||
currentModel={() => undefined}
|
||||
variants={() => []}
|
||||
currentVariant={() => undefined}
|
||||
@@ -72,6 +71,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
|
||||
turn_summary: "show",
|
||||
footer: "show",
|
||||
splash: "show",
|
||||
work_spinner: "block-soft-slide",
|
||||
mono: false,
|
||||
})}
|
||||
mono={false}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { RunFooter } from "../../src/mini/footer"
|
||||
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
|
||||
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
|
||||
import type { RunPrompt } from "../../src/mini/types"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
const sizes = [
|
||||
[112, 30],
|
||||
[24, 8],
|
||||
[40, 12],
|
||||
[112, 30],
|
||||
] as const
|
||||
|
||||
async function setup(mono = false) {
|
||||
const app = await createTestRenderer({
|
||||
width: 112,
|
||||
height: 30,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
externalOutputMode: "capture-stdout",
|
||||
})
|
||||
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
|
||||
const prompts: RunPrompt[] = []
|
||||
const footer = new RunFooter(app.renderer, {
|
||||
directory: () => "/project",
|
||||
findFiles: async () => ["packages/tui/src/mini/footer.view.tsx", "packages/tui/src/mini/", "src/\u6587\u4ef6.ts"],
|
||||
agents: [{ id: "build", name: "Build", mode: "primary", hidden: false }],
|
||||
references: [],
|
||||
agent: "build",
|
||||
modelLabel: "GPT-5",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
first: false,
|
||||
theme,
|
||||
tuiConfig: createTuiResolvedConfig(),
|
||||
miniSettings: {
|
||||
current: { ...resolveMiniSettings(), mono },
|
||||
},
|
||||
onPermissionReply: () => {},
|
||||
onFormReply: () => {},
|
||||
onFormCancel: () => {},
|
||||
onEditorOpen: async () => undefined,
|
||||
subscribeThemeSignal: () => () => {},
|
||||
})
|
||||
footer.onPrompt((prompt) => prompts.push(prompt))
|
||||
footer.event({ type: "catalog", agents: [], references: [], commands: [] })
|
||||
return {
|
||||
...app,
|
||||
footer,
|
||||
prompts,
|
||||
selected() {
|
||||
return app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) =>
|
||||
mono
|
||||
? (span.attributes & TextAttributes.INVERSE) !== 0
|
||||
: span.bg.toInts().join() === (theme.footer.actionFocusedBg as RGBA).toInts().join(),
|
||||
)
|
||||
.map((span) => span.text)
|
||||
.join("")
|
||||
.trim()
|
||||
},
|
||||
async settle() {
|
||||
await app.renderOnce()
|
||||
await app.renderOnce()
|
||||
},
|
||||
cleanup() {
|
||||
footer.destroy()
|
||||
app.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test.each([56, 160])("production footer confirms exit with a visible menu hint at %i columns", async (width) => {
|
||||
const app = await setup()
|
||||
try {
|
||||
app.resize(width, 30)
|
||||
app.footer.event({ type: "stream.patch", patch: { phase: "running", usage: { tokens: 14100, percent: 1 } } })
|
||||
await app.settle()
|
||||
expect(app.captureCharFrame()).toContain("ctrl+p menu")
|
||||
app.footer.requestExit()
|
||||
await app.settle()
|
||||
expect(app.captureCharFrame()).toContain("Press ctrl+c again to exit")
|
||||
expect(app.captureCharFrame()).not.toContain("menu")
|
||||
expect(app.footer.isClosed).toBe(false)
|
||||
app.footer.requestExit()
|
||||
expect(app.footer.isClosed).toBe(true)
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])(
|
||||
"production statusline restores identity and trailing menu after resizes (mono=%s)",
|
||||
async (mono) => {
|
||||
const app = await setup(mono)
|
||||
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
|
||||
try {
|
||||
app.footer.event({
|
||||
type: "models",
|
||||
providers: [
|
||||
{ id: "opencode", name: "Anomaly / OpenCode", models: { "gpt-5.6-sol": { name: "GPT-5.6 Sol (50% Off)" } } },
|
||||
],
|
||||
})
|
||||
app.footer.event({
|
||||
type: "model",
|
||||
model: "GPT-5.6 Sol (50% Off)",
|
||||
selection: { providerID: "opencode", modelID: "gpt-5.6-sol" },
|
||||
})
|
||||
app.footer.event({ type: "variants", variants: ["max"], current: "max" })
|
||||
app.footer.event({ type: "stream.patch", patch: { usage: { tokens: 14100, percent: 1, cost: 0.04 } } })
|
||||
await app.settle()
|
||||
const initial = app.captureCharFrame()
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
const statusline = app.renderer.root.findDescendantById("mini-statusline")!
|
||||
const frame = app.captureCharFrame()
|
||||
const row = frame.split("\n")[statusline.y].trimEnd()
|
||||
const model = width === 112 ? "GPT-5.6 Sol (50% Off) [max]" : mono ? "GPT-5.6... [max]" : "GPT-5.6\u2026 [max]"
|
||||
expect(row).toBe(
|
||||
(width === 112
|
||||
? ["Build", model, "14.1K (1%)", "$0.04", "Anomaly / OpenCode", "ctrl+p menu"]
|
||||
: width === 40
|
||||
? ["Build", model, "1% ctx"]
|
||||
: ["Build", model]
|
||||
).join(mono ? " - " : " \u00b7 "),
|
||||
)
|
||||
expect(statusline.height).toBe(1)
|
||||
expect(app.renderer.footerHeight).toBe(height === 30 ? 4 : 2)
|
||||
const identity = app.captureSpans().lines[statusline.y].spans.find((span) => span.text.includes("[max]"))!
|
||||
expect(identity.text).toContain(model)
|
||||
expect(identity.fg.toInts()).toEqual((theme.footer.text as RGBA).toInts())
|
||||
if (width === 112) expect(frame).toBe(initial)
|
||||
if (mono) expect(frame).not.toMatch(/[^\x00-\x7f]/)
|
||||
}
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each([false, true])("production command menu keeps navigation visible across resizes (mono=%s)", async (mono) => {
|
||||
const app = await setup(mono)
|
||||
try {
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("END")
|
||||
await app.settle()
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
|
||||
expect(app.captureCharFrame()).toContain("esc")
|
||||
expect(app.selected()).toContain("Exit")
|
||||
expect(app.footer.isClosed).toBe(false)
|
||||
}
|
||||
for (const [key, title] of [
|
||||
["ARROW_UP", "Settings"],
|
||||
["ARROW_DOWN", "Exit"],
|
||||
["HOME", "Open editor"],
|
||||
["ARROW_DOWN", "Show status"],
|
||||
["END", "Exit"],
|
||||
]) {
|
||||
app.mockInput.pressKey(key)
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(title)
|
||||
}
|
||||
app.mockInput.pressEnter()
|
||||
expect(app.footer.isClosed).toBe(true)
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])(
|
||||
"production model menu keeps labels and current state before metadata (mono=%s)",
|
||||
async (mono) => {
|
||||
const app = await setup(mono)
|
||||
const title = "\u6a21\u578b\u6d4b\u8bd5 Alpha"
|
||||
try {
|
||||
app.footer.event({
|
||||
type: "models",
|
||||
providers: [
|
||||
{
|
||||
id: "provider-with-long-id",
|
||||
name: "Provider with a very long name",
|
||||
models: Object.fromEntries(
|
||||
Array.from({ length: 22 }, (_, index) => [
|
||||
"very-long-internal-model-id-" + index,
|
||||
{ name: index === 0 ? title : `Model ${String(index).padStart(2, "0")}` },
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.settle()
|
||||
await app.mockInput.typeText("switch model")
|
||||
app.mockInput.pressEnter()
|
||||
await app.settle()
|
||||
for (const [key, text] of [
|
||||
["HOME", "Model 01"],
|
||||
["ARROW_DOWN", "Model 02"],
|
||||
["ARROW_UP", "Model 01"],
|
||||
["END", title],
|
||||
]) {
|
||||
app.mockInput.pressKey(key)
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(text)
|
||||
}
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(title)
|
||||
expect(app.captureCharFrame()).toContain("esc")
|
||||
}
|
||||
app.resize(24, 8)
|
||||
await app.settle()
|
||||
expect(app.selected()).toBe(title)
|
||||
expect(app.captureCharFrame()).not.toContain("very-long")
|
||||
app.mockInput.pressEnter()
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.settle()
|
||||
await app.mockInput.typeText("switch model")
|
||||
app.mockInput.pressEnter()
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("current")
|
||||
expect(app.selected()).toContain("\u6a21\u578b\u6d4b\u8bd5")
|
||||
expect(app.selected()).not.toContain("very-long")
|
||||
for (const width of [16, 20, 32, 40]) {
|
||||
app.resize(width, 8)
|
||||
await app.settle()
|
||||
expect(app.captureCharFrame()).toContain("esc")
|
||||
expect(app.selected()).toContain(title)
|
||||
expect(app.selected().includes("current")).toBe(width >= 24)
|
||||
}
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("subagent menu recalculates its twelve-row window after shrink, filter, and growth", async () => {
|
||||
const app = await setup()
|
||||
try {
|
||||
app.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: Array.from({ length: 24 }, (_, index) => ({
|
||||
sessionID: `child-${index}`,
|
||||
label: "Worker",
|
||||
description: `Task ${String(index).padStart(2, "0")}`,
|
||||
status: "running",
|
||||
})),
|
||||
details: {},
|
||||
permissions: [],
|
||||
forms: [],
|
||||
},
|
||||
})
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
await app.settle()
|
||||
for (const [key, title] of [
|
||||
["END", "Task 23"],
|
||||
["ARROW_UP", "Task 22"],
|
||||
["HOME", "Task 00"],
|
||||
["ARROW_DOWN", "Task 01"],
|
||||
["END", "Task 23"],
|
||||
]) {
|
||||
app.mockInput.pressKey(key)
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(title)
|
||||
}
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
|
||||
expect(app.selected()).toContain("Task 23")
|
||||
expect(app.selected()).toContain("running")
|
||||
}
|
||||
await app.mockInput.typeText("Task 23")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("Task 23")
|
||||
app.mockInput.pressKey("u", { ctrl: true })
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("HOME")
|
||||
for (const [height, title] of [
|
||||
[8, "Task 04"],
|
||||
[12, "Task 08"],
|
||||
[30, "Task 11"],
|
||||
] as const) {
|
||||
app.resize(24, height)
|
||||
await app.settle()
|
||||
app.mockInput.pressKey("\x1b[6~")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(title)
|
||||
app.mockInput.pressKey("\x1b[5~")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("Task 00")
|
||||
}
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("wrapped notices reserve space beside a six-line draft (mono=%s)", async (mono) => {
|
||||
const app = await setup(mono)
|
||||
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
|
||||
try {
|
||||
await app.settle()
|
||||
app.mockInput.pasteBracketedText(draft)
|
||||
app.footer.event({ type: "stream.patch", patch: { notice: "failed to save settings" } })
|
||||
for (const width of [16, 24, 40]) {
|
||||
app.resize(width, 8)
|
||||
await app.flush()
|
||||
expect(app.renderer.footerHeight).toBeLessThanOrEqual(7)
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
|
||||
expect(app.captureCharFrame().replace(/\s+/g, " ")).toContain("failed to save settings")
|
||||
expect(app.captureCharFrame()).toContain("sixth")
|
||||
}
|
||||
app.footer.event({ type: "stream.patch", patch: { notice: "" } })
|
||||
await app.flush()
|
||||
expect(app.captureCharFrame()).toContain("Build")
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("composer and autocomplete share the physical height budget (mono=%s)", async (mono) => {
|
||||
const app = await setup(mono)
|
||||
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
|
||||
try {
|
||||
await app.settle()
|
||||
app.mockInput.pasteBracketedText(draft)
|
||||
await app.flush()
|
||||
await app.mockInput.typeText(" @f")
|
||||
await app.settle()
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
|
||||
expect(app.captureCharFrame()).toContain("sixth @f")
|
||||
expect(app.selected()).toContain(width === 24 ? "@mini/footer.view.tsx" : "footer.view.tsx")
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("mini/")
|
||||
app.mockInput.pressKey("ARROW_UP")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("footer.view.tsx")
|
||||
}
|
||||
app.resize(24, 8)
|
||||
await app.settle()
|
||||
app.mockInput.pressEnter()
|
||||
await app.settle()
|
||||
const text = draft + " @packages/tui/src/mini/footer.view.tsx "
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe(text)
|
||||
app.mockInput.pressEnter()
|
||||
await app.settle()
|
||||
expect(app.prompts).toHaveLength(1)
|
||||
expect(app.prompts[0].text).toBe(text)
|
||||
expect(app.prompts[0].parts[0]).toMatchObject({
|
||||
type: "file",
|
||||
filename: "packages/tui/src/mini/footer.view.tsx",
|
||||
source: {
|
||||
type: "file",
|
||||
path: "packages/tui/src/mini/footer.view.tsx",
|
||||
text: {
|
||||
start: draft.length + 1,
|
||||
end: text.length - 1,
|
||||
value: "@packages/tui/src/mini/footer.view.tsx",
|
||||
},
|
||||
},
|
||||
})
|
||||
app.footer.event({
|
||||
type: "catalog",
|
||||
agents: [],
|
||||
references: [],
|
||||
commands: Array.from({ length: 24 }, (_, index) => ({
|
||||
name: `cmd-${String(index).padStart(2, "0")}`,
|
||||
description: "Optional command description",
|
||||
})),
|
||||
})
|
||||
await app.mockInput.typeText("/")
|
||||
for (const [width, height] of sizes) {
|
||||
app.resize(width, height)
|
||||
await app.settle()
|
||||
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
|
||||
for (let index = 1; index <= 12; index++) {
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain(`/cmd-${String(index).padStart(2, "0")}`)
|
||||
}
|
||||
for (let index = 0; index < 12; index++) app.mockInput.pressKey("ARROW_UP")
|
||||
await app.settle()
|
||||
expect(app.selected()).toContain("/cmd-00")
|
||||
}
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { coalesceProgressCommit, resolveRunAgent } from "../../src/mini/footer"
|
||||
import type { RunAgent, StreamCommit } from "../../src/mini/types"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { CliRenderEvents, RGBA, TextRenderable } from "@opentui/core"
|
||||
import path from "node:path"
|
||||
import { coalesceProgressCommit, resolveRunAgent, RunFooter } from "../../src/mini/footer"
|
||||
import { createRunDemo } from "../../src/mini/demo"
|
||||
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
|
||||
import { RUN_THEME_FALLBACK, RUN_THEME_FALLBACK_LIGHT, RUN_THEME_MONO } from "../../src/mini/theme"
|
||||
import type { MiniSettingChange, MiniSettings, RunAgent, RunTuiConfig, StreamCommit } from "../../src/mini/types"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
|
||||
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
|
||||
return {
|
||||
@@ -36,3 +45,295 @@ test("falls back only when no agent is selected", () => {
|
||||
expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
|
||||
expect(resolveRunAgent(agents, "missing")).toBeUndefined()
|
||||
})
|
||||
|
||||
async function setup(
|
||||
input: {
|
||||
mono?: boolean
|
||||
theme?: RunTuiConfig["theme"]
|
||||
startup?: { version: string; detail: string }
|
||||
update?: (change: MiniSettingChange) => Promise<MiniSettings>
|
||||
} = {},
|
||||
) {
|
||||
const mono = input.mono ?? true
|
||||
const app = await createTestRenderer({
|
||||
width: 112,
|
||||
height: 24,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
externalOutputMode: "capture-stdout",
|
||||
})
|
||||
const footer = new RunFooter(app.renderer, {
|
||||
directory: () => "/project",
|
||||
findFiles: async () => [],
|
||||
agents: [{ id: "build", name: "Build", mode: "primary", hidden: false }],
|
||||
references: [],
|
||||
agent: "build",
|
||||
modelLabel: "GPT-5",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
first: true,
|
||||
startup: input.startup,
|
||||
wrote: !!input.startup,
|
||||
theme: mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK,
|
||||
tuiConfig: createTuiResolvedConfig({ theme: input.theme }),
|
||||
miniSettings: {
|
||||
current: { ...resolveMiniSettings(), mono },
|
||||
update: input.update,
|
||||
},
|
||||
onPermissionReply: () => {},
|
||||
onFormReply: () => {},
|
||||
onFormCancel: () => {},
|
||||
onEditorOpen: async () => undefined,
|
||||
subscribeThemeSignal: () => () => {},
|
||||
})
|
||||
return { ...app, footer }
|
||||
}
|
||||
|
||||
test.each(["timer", "output", "close", "panel"] as const)(
|
||||
"startup stays editable and settles exactly once before %s",
|
||||
async (finish) => {
|
||||
const app = await setup({ mono: false, startup: { version: "test", detail: "/project" } })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.findDescendantById("mini-startup")).toBeDefined()
|
||||
const editor = app.renderer.currentFocusedEditor
|
||||
await app.mockInput.typeText("draft")
|
||||
expect(editor?.plainText).toBe("draft")
|
||||
expect(app.externalOutput.take()).toEqual([])
|
||||
|
||||
if (finish === "timer") await Bun.sleep(850)
|
||||
if (finish === "output")
|
||||
app.footer.append({ kind: "system", text: "first output", phase: "start", source: "system" })
|
||||
if (finish === "close") app.footer.close()
|
||||
if (finish === "panel") app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.footer.idle()
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.findDescendantById("mini-startup")).toBeUndefined()
|
||||
const rows = app.externalOutput.take().flatMap((event) => event.rows)
|
||||
expect(rows.filter((row) => row.includes("oc mini"))).toEqual(["\u25aa oc mini vtest \u00b7 /project"])
|
||||
if (finish === "output")
|
||||
expect(rows.findIndex((row) => row.includes("first output"))).toBeGreaterThan(
|
||||
rows.findIndex((row) => row.includes("oc mini")),
|
||||
)
|
||||
if (finish !== "panel") expect(app.renderer.currentFocusedEditor).toBe(editor)
|
||||
app.footer.finishStartup()
|
||||
await app.renderOnce()
|
||||
expect(app.externalOutput.take()).toEqual([])
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("footer usage survives unrelated patches and clears when explicitly undefined", async () => {
|
||||
const app = await setup()
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("7.5K")
|
||||
app.footer.event({ type: "stream.patch", patch: { first: false, usage: { tokens: 7_508, percent: 5 } } })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("7.5K")
|
||||
app.footer.event({ type: "stream.patch", patch: { model: "GPT-5.1" } })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("7.5K")
|
||||
app.footer.event({ type: "stream.patch", patch: { usage: undefined } })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("7.5K")
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("motion demo waits for work and can be interrupted without a model call", async () => {
|
||||
const footer = createFooterApiFixture()
|
||||
const controller = new AbortController()
|
||||
const demo = createRunDemo({ sessionID: "seed-demo", thinking: false, footer: footer.api })
|
||||
let finished = false
|
||||
try {
|
||||
expect(demo.interrupt()).toBe(false)
|
||||
const run = demo.prompt({ text: "/fmt motion", parts: [] }, controller.signal).then((handled) => {
|
||||
finished = true
|
||||
return handled
|
||||
})
|
||||
await footer.api.idle()
|
||||
expect(finished).toBe(false)
|
||||
expect(demo.interrupt()).toBe(true)
|
||||
expect(await run).toBe(true)
|
||||
expect(demo.interrupt()).toBe(false)
|
||||
} finally {
|
||||
controller.abort()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("command menu uses its full height on first open (mono=%s)", async (mono) => {
|
||||
const app = await setup({ mono })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
expect(frame).toContain("Open editor")
|
||||
expect(frame).toContain("Show status")
|
||||
expect(frame).toContain("Compact session")
|
||||
expect(frame).toContain("New session")
|
||||
expect(frame).toContain("Skills")
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("monochrome toggles live without replacing the footer (initial=%s)", async (mono) => {
|
||||
const changes: MiniSettingChange[] = []
|
||||
const app = await setup({
|
||||
mono,
|
||||
theme: { name: "system" },
|
||||
update: async (change) => {
|
||||
changes.push(change)
|
||||
return { ...resolveMiniSettings(), [change.key]: change.value }
|
||||
},
|
||||
})
|
||||
app.renderer.getPalette = async () => {
|
||||
throw new Error("no OSC response")
|
||||
}
|
||||
const output: string[] = []
|
||||
app.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
|
||||
output.push(new TextDecoder().decode(event.snapshot.getRealCharBytes(true)))
|
||||
})
|
||||
try {
|
||||
await app.mockInput.pressKeys(["\x1b]10;rgb:ffff/ffff/ffff\x07", "\x1b]11;rgb:0000/0000/0000\x07"])
|
||||
expect(app.renderer.themeMode).toBe("dark")
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("draft")
|
||||
app.mockInput.pressKey("p", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("settings")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("monochrome")
|
||||
await app.renderOnce()
|
||||
const search = app.renderer.currentFocusedRenderable
|
||||
for (const next of [!mono, mono]) {
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.flush()
|
||||
await app.footer.idle()
|
||||
await app.renderOnce()
|
||||
expect(app.footer.currentMiniSettings().mono).toBe(next)
|
||||
expect(app.footer.currentTheme()).toBe(next ? RUN_THEME_MONO : RUN_THEME_FALLBACK)
|
||||
expect(app.renderer.currentFocusedRenderable).toBe(search)
|
||||
expect(app.captureCharFrame()).toContain("monochrome")
|
||||
expect(app.captureCharFrame()).not.toContain("restart")
|
||||
app.footer.append({ kind: "user", text: "new output", phase: "start", source: "system" })
|
||||
await app.footer.idle()
|
||||
expect(output.at(-1)).toContain(next ? "> new output" : "\u203a new output")
|
||||
app.renderer.writeToScrollback((ctx) => ({
|
||||
root: new TextRenderable(ctx.renderContext, { content: "external \u2192", width: ctx.width, height: 1 }),
|
||||
height: 1,
|
||||
}))
|
||||
expect(output.at(-1)).toContain(next ? "external ?" : "external \u2192")
|
||||
}
|
||||
expect(changes).toEqual([
|
||||
{ key: "mono", value: !mono },
|
||||
{ key: "mono", value: mono },
|
||||
])
|
||||
expect(output.filter((text) => text.includes("new output"))).toHaveLength(2)
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("draft")
|
||||
expect(app.captureCharFrame()).toContain(mono ? "| draft" : "\u2503 draft")
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("production footer preserves wrapped input and status on resize (mono=%s)", async (mono) => {
|
||||
const app = await setup({ mono })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const draft =
|
||||
"Explain how this project is organized, then outline a small change and the checks needed to verify it. Do not modify files."
|
||||
await app.mockInput.typeText(draft)
|
||||
for (const width of [56, 112, 40]) {
|
||||
app.resize(width, 24)
|
||||
await app.renderOnce()
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor!.plainText).toBe(draft)
|
||||
const frame = app.captureCharFrame()
|
||||
expect(frame.split("\n").filter((line) => line.startsWith(mono ? "| " : "┃ "))).toHaveLength(
|
||||
app.renderer.currentFocusedEditor!.virtualLineCount,
|
||||
)
|
||||
expect(app.renderer.currentFocusedEditor!.virtualLineCount).toBeGreaterThan(1)
|
||||
expect(frame).toContain("Build")
|
||||
expect(frame).toContain("GPT-5")
|
||||
}
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("explicit theme refresh reloads custom colors without a palette event", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const previous = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = tmp.path
|
||||
const app = await setup({ mono: false, theme: { name: "mini-refresh", mode: "dark" } })
|
||||
app.renderer.getPalette = async () => {
|
||||
throw new Error("no OSC response")
|
||||
}
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("draft")
|
||||
for (const color of ["#123456", "#abcdef"]) {
|
||||
await Bun.write(
|
||||
path.join(tmp.path, "themes", "mini-refresh.json"),
|
||||
JSON.stringify({ version: 2, dark: { text: { default: color } } }),
|
||||
)
|
||||
await app.footer.refreshTheme()
|
||||
await app.renderOnce()
|
||||
expect(
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.includes("draft"))
|
||||
?.fg.toInts(),
|
||||
).toEqual(RGBA.fromHex(color).toInts())
|
||||
}
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
if (previous === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||
else process.env.OPENCODE_CONFIG_DIR = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("system fallback follows physical mode changes when palette queries remain unavailable", async () => {
|
||||
const app = await setup({ mono: false, theme: { name: "system" } })
|
||||
app.renderer.getPalette = async () => {
|
||||
throw new Error("no OSC palette response")
|
||||
}
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("draft")
|
||||
expect(app.footer.currentTheme()).toBe(RUN_THEME_FALLBACK)
|
||||
await app.mockInput.pressKeys(["\x1b]10;rgb:0000/0000/0000\x07", "\x1b]11;rgb:ffff/ffff/ffff\x07"])
|
||||
expect(app.renderer.themeMode).toBe("light")
|
||||
await app.waitFor(() => app.footer.currentTheme() === RUN_THEME_FALLBACK_LIGHT)
|
||||
await app.flush()
|
||||
expect(app.footer.currentTheme()).toBe(RUN_THEME_FALLBACK_LIGHT)
|
||||
expect(
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.includes("draft"))
|
||||
?.fg.toInts(),
|
||||
).toEqual((RUN_THEME_FALLBACK_LIGHT.footer.text as RGBA).toInts())
|
||||
} finally {
|
||||
app.footer.destroy()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,120 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { footerWidthPolicy } from "../../src/mini/footer.width"
|
||||
import { footerStatuslinePolicy, footerWidthPolicy, type FooterStatuslineGroup } from "../../src/mini/footer.width"
|
||||
import { stringWidth } from "../../src/util/string-width"
|
||||
|
||||
const screenshot = {
|
||||
work: [],
|
||||
model: { name: "GPT-5.6 Sol (50% Off)", variant: "max" },
|
||||
agent: "Build",
|
||||
context: { compact: "1% ctx", full: "14.1K (1%)" },
|
||||
cost: "$0.04",
|
||||
provider: "Anomaly / OpenCode",
|
||||
menu: { key: "ctrl+p", label: "menu" },
|
||||
} satisfies Omit<Parameters<typeof footerStatuslinePolicy>[0], "width">
|
||||
|
||||
describe("run footer width", () => {
|
||||
test("preserves the dialog breakpoint", () => {
|
||||
expect(footerWidthPolicy(79).dialog.narrow).toBe(true)
|
||||
expect(footerWidthPolicy(80).dialog.narrow).toBe(false)
|
||||
})
|
||||
|
||||
test.each([false, true])("progressive layouts fit and preserve information at every width (mono=%s)", (mono) => {
|
||||
const fixtures: Array<Omit<Parameters<typeof footerStatuslinePolicy>[0], "width">> = [
|
||||
{ work: [] },
|
||||
screenshot,
|
||||
{ ...screenshot, model: { name: "GPT-5", variant: "xhigh" } },
|
||||
{ ...screenshot, model: { name: "a model name with a very long and informative suffix".repeat(3) } },
|
||||
{
|
||||
...screenshot,
|
||||
model: { name: "\u6a21\u578b\ud83d\ude80e\u0301\u5f00\u53d1 Model".repeat(3), variant: "\u9ad8\u7ea7" },
|
||||
},
|
||||
{ ...screenshot, model: { name: "GPT-5", variant: "an-unusually-long-but-complete-variant" } },
|
||||
{ ...screenshot, status: { text: "esc stop", expanded: "esc interrupt" }, spinner: mono ? "*" : "\u25aa" },
|
||||
{
|
||||
...screenshot,
|
||||
status: { text: "ctrl+shift+alt+i stop", expanded: "ctrl+shift+alt+i interrupt" },
|
||||
work: [
|
||||
{ id: "queued", key: "ctrl+x q", label: "12 queued" },
|
||||
{ id: "subagents", key: mono ? "down" : "\u2193", label: "2 sub", expanded: "2 subagents" },
|
||||
{ id: "background", key: "ctrl+b", label: "bg", expanded: "background" },
|
||||
],
|
||||
},
|
||||
{ work: [], status: { text: "Shell" }, escape: { key: "esc", label: "normal" }, context: screenshot.context },
|
||||
]
|
||||
for (const fixture of fixtures) {
|
||||
let previous: FooterStatuslineGroup["id"][] = []
|
||||
for (let width = 0; width <= 280; width++) {
|
||||
const layout = footerStatuslinePolicy({ ...fixture, mono, width })
|
||||
const ids = layout.groups.map((group) => group.id)
|
||||
if (fixture.spinner) {
|
||||
expect(ids[0]).toBe("spinner")
|
||||
expect(layout.text).toStartWith(`${fixture.spinner} `)
|
||||
if (width >= stringWidth(`${fixture.spinner} ${fixture.status!.text}`)) {
|
||||
expect(stringWidth(layout.text)).toBeLessThanOrEqual(width)
|
||||
}
|
||||
}
|
||||
if (stringWidth(layout.text) > width) {
|
||||
expect(ids.every((id) => id === "spinner" || id === "status" || id === "escape")).toBe(true)
|
||||
}
|
||||
for (const id of previous) expect(ids).toContain(id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
if (ids.includes("menu")) expect(ids.at(-1)).toBe("menu")
|
||||
const identity = layout.groups.find((group) => group.id === "model")
|
||||
if (identity && fixture.model?.variant) {
|
||||
expect(identity.parts.map((part) => part.text).join("")).toEndWith(` [${fixture.model.variant}]`)
|
||||
}
|
||||
expect(layout.text).not.toContain("\ufffd")
|
||||
previous = ids
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("identity admission cannot separate the selected variant from its model", () => {
|
||||
const input = { work: [], model: { name: "GPT-5", variant: "max" } }
|
||||
expect(footerStatuslinePolicy({ ...input, width: 10 }).groups).toEqual([])
|
||||
expect(footerStatuslinePolicy({ ...input, width: 11 }).text).toBe("GPT-5 [max]")
|
||||
})
|
||||
|
||||
test("allocation priority is separate from placement", () => {
|
||||
expect(footerStatuslinePolicy({ ...screenshot, width: 15 }).groups.map((group) => group.id)).toEqual(["model"])
|
||||
const full = footerStatuslinePolicy({ ...screenshot, width: 112 })
|
||||
expect(full.text).toBe(
|
||||
"Build \u00b7 GPT-5.6 Sol (50% Off) [max] \u00b7 14.1K (1%) \u00b7 $0.04 \u00b7 Anomaly / OpenCode \u00b7 ctrl+p menu",
|
||||
)
|
||||
})
|
||||
|
||||
test("a non-fitting high-priority stage does not backfill with shorter facts", () => {
|
||||
const input = {
|
||||
...screenshot,
|
||||
work: [{ id: "queued" as const, key: "ctrl+shift+alt+q", label: "12 queued" }],
|
||||
}
|
||||
expect(footerStatuslinePolicy({ ...input, width: 24 }).groups).toEqual([])
|
||||
expect(footerStatuslinePolicy({ ...input, width: 26 }).groups.map((group) => group.id)).toEqual(["queued"])
|
||||
})
|
||||
|
||||
test("context and cost are distinct additions", () => {
|
||||
const input = { work: [], context: screenshot.context, cost: screenshot.cost }
|
||||
expect(footerStatuslinePolicy({ ...input, width: 6 }).text).toBe("1% ctx")
|
||||
expect(footerStatuslinePolicy({ ...input, width: 10 }).text).toBe("14.1K (1%)")
|
||||
expect(footerStatuslinePolicy({ ...input, width: 18 }).text).toBe("14.1K (1%) \u00b7 $0.04")
|
||||
})
|
||||
|
||||
test("stop-label enhancement cannot remove identity at the former 56-column breakpoint", () => {
|
||||
const input = { ...screenshot, status: { text: "esc stop", expanded: "esc interrupt" } }
|
||||
for (const width of [55, 56, 57, 79, 80, 81]) {
|
||||
const layout = footerStatuslinePolicy({ ...input, width })
|
||||
expect(layout.text).toContain("GPT-5.6 Sol (50% Off) [max]")
|
||||
expect(layout.text).toStartWith("esc stop")
|
||||
}
|
||||
expect(footerStatuslinePolicy({ ...input, width: 160 }).text).toStartWith("esc interrupt")
|
||||
})
|
||||
|
||||
test("required controls use an explicit wrapping fallback without optional groups", () => {
|
||||
expect(footerStatuslinePolicy({ ...screenshot, width: 16, status: { text: "ctrl+shift+alt+x exit" } }).text).toBe(
|
||||
"ctrl+shift+alt+x exit",
|
||||
)
|
||||
expect(
|
||||
footerStatuslinePolicy({ ...screenshot, width: 80, status: { text: "Please confirm\nExit now?" } }).text,
|
||||
).toBe("Please confirm\nExit now?")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("run runtime boot", () => {
|
||||
turn_summary: "show",
|
||||
footer: "show",
|
||||
splash: "show",
|
||||
work_spinner: "block-soft-slide",
|
||||
mono: false,
|
||||
})
|
||||
expect(
|
||||
@@ -53,6 +54,7 @@ describe("run runtime boot", () => {
|
||||
turn_summary: "hide",
|
||||
footer: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
},
|
||||
}),
|
||||
@@ -62,6 +64,7 @@ describe("run runtime boot", () => {
|
||||
turn_summary: "hide",
|
||||
footer: "hide",
|
||||
splash: "hide",
|
||||
work_spinner: "block-low-comet",
|
||||
mono: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,37 @@ describe("run runtime queue", () => {
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
test("runs and queues image-only prompts without optimistic image rows", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const active = Promise.withResolvers<void>()
|
||||
const queued = Promise.withResolvers<RunPrompt>()
|
||||
const prompt: RunPrompt = {
|
||||
text: "",
|
||||
parts: [{ type: "file", url: "data:image/png;base64,cG5n", mime: "image/png", filename: "image.png" }],
|
||||
}
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input, _signal, admitted) => {
|
||||
expect(input.parts).toEqual(prompt.parts)
|
||||
admitted()
|
||||
await active.promise
|
||||
},
|
||||
admit: async (input, delivery) => {
|
||||
expect(delivery).toBe("queue")
|
||||
queued.resolve(input)
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit({ ...prompt, mode: "shell" })
|
||||
ui.submit(prompt)
|
||||
ui.submit(prompt)
|
||||
expect((await queued.promise).parts).toEqual(prompt.parts)
|
||||
expect(ui.commits).toEqual([])
|
||||
active.resolve()
|
||||
await task
|
||||
})
|
||||
|
||||
test("treats /exit as a close command", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
let calls = 0
|
||||
|
||||
@@ -21,6 +21,7 @@ function ok<T>(data: T) {
|
||||
|
||||
function host(): MiniHost {
|
||||
return {
|
||||
version: "local",
|
||||
terminal: { stdin: process.stdin },
|
||||
platform: "linux",
|
||||
stdout: { write() {} },
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
ImageRenderable,
|
||||
TextRenderable,
|
||||
type NativeImage,
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
||||
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { diffImageFixture } from "../fixture/diff-image"
|
||||
|
||||
const wide = `data:image/png;base64,${Buffer.from(diffImageFixture).toString("base64")}`
|
||||
// A 48 x 192 PNG with four colored quadrants.
|
||||
const tall =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAADACAIAAADN8nfJAAAA+klEQVR4nO3bUQ0AIAxDwUmZEETMf+YFTDR8XYIAQmBs7WvdM5HVoVU25ITcIa9MHVIYfR0+V+2HBk3HqKc2dZjLDIpGaWIDOYZgRUGjMVJh6dSEc9YC84U9xcDjKPJcudJ8eyAB1AKMAtcBNCGsMGgoPRwjsBJ6Cs6FLwO8EeeYfKkFuQ5BE1EcYaU/ca6dyOrQsiEn5A55ZeqQwujr8LlqPzRoOkY9tanDXGZQNEoTG8gxBCsKGo2RCkunJpyzFpgv7CkGHkeR58qV5tsDCaAWYBS4DqAJYYVBQ+nhGIGV0FNwLnwZ4I04x+RLLch1CJqI4ggr7Y841wMtbiD+34CxQgAAAABJRU5ErkJggg=="
|
||||
|
||||
type Preview = {
|
||||
surface: ScrollbackSurface
|
||||
image: ImageRenderable
|
||||
native: NativeImage | null
|
||||
size: { width: number; height: number }
|
||||
x: number
|
||||
y: number
|
||||
captionHeight: number
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup())
|
||||
})
|
||||
|
||||
async function setup(
|
||||
options: { width?: number; height?: number; footerHeight?: number; imagePreview?: boolean; mono?: boolean } = {},
|
||||
) {
|
||||
const renderer = await createTestRenderer({
|
||||
width: options.width ?? 80,
|
||||
height: options.height ?? 24,
|
||||
footerHeight: options.footerHeight ?? 6,
|
||||
screenMode: "split-footer",
|
||||
externalOutputMode: "capture-stdout",
|
||||
})
|
||||
const out = {
|
||||
...renderer,
|
||||
scrollback: new RunScrollbackStream(renderer.renderer, options.mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK, options),
|
||||
previews: [] as Preview[],
|
||||
}
|
||||
cleanups.push(() => {
|
||||
out.scrollback.destroy()
|
||||
out.renderer.destroy()
|
||||
})
|
||||
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, () => {
|
||||
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface | undefined
|
||||
if (!surface || out.previews.some((preview) => preview.surface === surface)) return
|
||||
const image = surface.root.getChildren().find((child) => child instanceof ImageRenderable)
|
||||
const caption = surface.root.getChildren().find((child) => child instanceof TextRenderable)
|
||||
if (!image || !caption) throw new Error("Missing image or caption on image surface")
|
||||
out.previews.push({
|
||||
surface,
|
||||
image,
|
||||
native: image.image,
|
||||
size: { width: image.width, height: image.height },
|
||||
x: image.x,
|
||||
y: image.y,
|
||||
captionHeight: caption.height,
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function image(source = wide, text = "landscape.png", partID = "image-1"): StreamCommit {
|
||||
return { kind: "user", source: "system", phase: "final", messageID: "message-1", partID, text, image: source }
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "wide",
|
||||
image: wide,
|
||||
width: 40,
|
||||
height: 30,
|
||||
size: { width: 40, height: 10 },
|
||||
},
|
||||
{
|
||||
name: "tall",
|
||||
image: tall,
|
||||
width: 80,
|
||||
height: 24,
|
||||
size: { width: 9, height: 17 },
|
||||
},
|
||||
])("fits a $name image at the left edge and commits only its fitted rows", async (options) => {
|
||||
const out = await setup({ ...options, imagePreview: true })
|
||||
const listeners = out.renderer.listenerCount(CliRenderEvents.DESTROY)
|
||||
await out.scrollback.append(image(options.image))
|
||||
|
||||
expect(out.previews).toHaveLength(1)
|
||||
const preview = out.previews[0]!
|
||||
expect(preview.size).toEqual(options.size)
|
||||
expect(preview.x).toBe(0)
|
||||
expect(preview.y).toBe(preview.captionHeight)
|
||||
const commits = out.externalOutput.take()
|
||||
expect(commits).toHaveLength(1)
|
||||
expect(commits[0]!.rows[0]).toBe("\u203a landscape.png")
|
||||
expect(commits[0]!.height).toBe(options.size.height + preview.captionHeight)
|
||||
expect(preview.surface.isDestroyed).toBe(true)
|
||||
expect(preview.image.isDestroyed).toBe(true)
|
||||
expect(preview.image.image).toBeNull()
|
||||
expect(() => preview.native!.info()).toThrow()
|
||||
expect(out.renderer.listenerCount(CliRenderEvents.DESTROY)).toBe(listeners)
|
||||
})
|
||||
|
||||
test("reserves wrapped caption rows and allows caption-only output in a short terminal", async () => {
|
||||
const out = await setup({ width: 20, height: 10, footerHeight: 6, imagePreview: true })
|
||||
await out.scrollback.append(image(tall, "first caption wraps here\nsecond caption line"))
|
||||
expect(out.previews[0]!.captionHeight).toBe(3)
|
||||
expect(out.previews[0]!.size.height).toBe(1)
|
||||
expect(out.externalOutput.take()[0]!.height).toBe(4)
|
||||
|
||||
out.resize(20, 8)
|
||||
await out.scrollback.append(image(wide, "first caption wraps here\nsecond caption line", "image-2"))
|
||||
expect(out.previews[1]!.image.visible).toBe(false)
|
||||
expect(out.externalOutput.take().at(-1)!.height).toBe(3)
|
||||
})
|
||||
|
||||
test("uses physical cell geometry without enlarging a small image", async () => {
|
||||
const out = await setup({ imagePreview: true })
|
||||
Reflect.set(out.renderer, "_resolution", { width: 800, height: 720 })
|
||||
await out.scrollback.append(image(wide))
|
||||
expect(out.previews[0]!.size).toEqual({ width: 6, height: 1 })
|
||||
expect(out.previews[0]!.x).toBe(0)
|
||||
})
|
||||
|
||||
test("finishes active text before every image and renders more than three images in order", async () => {
|
||||
const out = await setup({ imagePreview: true })
|
||||
const first = { ...image(wide, "image 1"), kind: "tool", source: "tool", tool: "read" } as const
|
||||
await out.scrollback.append({ ...first, image: undefined, tool: "shell", phase: "progress", text: "before images" })
|
||||
expect(out.externalOutput.take()).toEqual([])
|
||||
await out.scrollback.append(first)
|
||||
for (const index of [2, 3, 4, 5]) {
|
||||
await out.scrollback.append({ ...first, partID: `image-${index}`, text: `image ${index}`, image: tall })
|
||||
}
|
||||
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "after images" })
|
||||
await out.scrollback.complete()
|
||||
|
||||
expect(out.previews).toHaveLength(5)
|
||||
expect(
|
||||
out.externalOutput
|
||||
.take()
|
||||
.filter((commit) => commit.text.trim())
|
||||
.map((commit) => commit.rows[0]),
|
||||
).toEqual(["before images", "image 1", "image 2", "image 3", "image 4", "image 5", "after images"])
|
||||
expect(out.previews.every((preview) => preview.image.isDestroyed && preview.surface.isDestroyed)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([{}, { imagePreview: false }, { imagePreview: true, mono: true }])(
|
||||
"keeps user and tool captions without loading images when previews are disabled or monochrome (%j)",
|
||||
async (options) => {
|
||||
const out = await setup(options)
|
||||
const commit = image("data:image/png;base64,AQID", "attachment.png")
|
||||
await out.scrollback.append(commit)
|
||||
await out.scrollback.append({ ...commit, kind: "tool", source: "tool", tool: "read", partID: "image-2" })
|
||||
expect(out.previews).toEqual([])
|
||||
expect(
|
||||
out.externalOutput
|
||||
.take()
|
||||
.filter((commit) => commit.text.trim())
|
||||
.map((commit) => commit.rows[0]),
|
||||
).toEqual([`${options.mono ? ">" : "\u203a"} attachment.png`, "attachment.png"])
|
||||
},
|
||||
)
|
||||
|
||||
test("prints a failed decode caption and continues with text", async () => {
|
||||
const out = await setup({ imagePreview: true })
|
||||
await expect(out.scrollback.append(image("data:image/png;base64,AQID", "broken.png"))).resolves.toBeUndefined()
|
||||
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "still running" })
|
||||
const output = out.externalOutput.takeText()
|
||||
expect(output).toContain("\u203a broken.png\nNo preview")
|
||||
expect(output).toContain("still running")
|
||||
expect(out.previews[0]!.image.loadError).not.toBeNull()
|
||||
expect(out.previews[0]!.image.isDestroyed).toBe(true)
|
||||
expect(out.previews[0]!.surface.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("remeasures at load completion", async () => {
|
||||
const out = await setup({ imagePreview: true })
|
||||
const pending = out.scrollback.append(image(tall))
|
||||
await Promise.resolve()
|
||||
out.resize(40, 18)
|
||||
await pending
|
||||
expect(out.previews[0]!.size).toEqual({ width: 6, height: 11 })
|
||||
})
|
||||
|
||||
test.each(["stream", "renderer"])("disposes an image load when the %s is destroyed", async (owner) => {
|
||||
const out = await setup({ imagePreview: true })
|
||||
const pending = out.scrollback.append(image())
|
||||
await Promise.resolve()
|
||||
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface
|
||||
const preview = surface.root.getChildren().find((child) => child instanceof ImageRenderable)!
|
||||
expect(preview.loading).toBe(true)
|
||||
|
||||
if (owner === "stream") out.scrollback.destroy()
|
||||
if (owner === "renderer") out.renderer.destroy()
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
expect(surface.isDestroyed).toBe(true)
|
||||
expect(preview.isDestroyed).toBe(true)
|
||||
expect(preview.image).toBeNull()
|
||||
expect(out.externalOutput.take()).toEqual([])
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable } from "@opentui/core"
|
||||
import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextAttributes, TextRenderable } from "@opentui/core"
|
||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||
import { monoSnapshot } from "../../src/mini/mono"
|
||||
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
||||
import { entryLook } from "../../src/mini/scrollback.shared"
|
||||
import { entryGroupKey } from "../../src/mini/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||
import { RUN_THEME_FALLBACK, RUN_THEME_MONO, type RunTheme } from "../../src/mini/theme"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
@@ -213,6 +214,64 @@ test("theme swaps preserve streamed markdown parser state", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("monochrome switches preserve printed blocks and open fences (initial=%s)", async (mono) => {
|
||||
const out = await setup()
|
||||
try {
|
||||
await out.scrollback.setMono(mono)
|
||||
out.scrollback.setTheme(mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK)
|
||||
await out.scrollback.append(assistant('Printed block\n\n```ts\nconst arrow = "'))
|
||||
const printed = claim(out.renderer)
|
||||
try {
|
||||
expect(render(printed)).toContain("Printed block")
|
||||
expect(render(printed)).not.toContain("const arrow")
|
||||
await out.scrollback.setMono(!mono)
|
||||
out.scrollback.setTheme(mono ? RUN_THEME_FALLBACK : RUN_THEME_MONO)
|
||||
expect(render(claim(out.renderer))).toBe("")
|
||||
await out.scrollback.append(assistant('\u2192"\n```\n\nNext block'))
|
||||
await out.scrollback.complete()
|
||||
const next = claim(out.renderer)
|
||||
try {
|
||||
expect(render(next)).toContain(mono ? 'const arrow = "\u2192"' : 'const arrow = "->"')
|
||||
expect(render(next)).toContain("Next block")
|
||||
expect(render(next)).not.toContain("Printed block")
|
||||
expect(render(next)).not.toContain("```")
|
||||
expect(render(printed)).toContain("Printed block")
|
||||
} finally {
|
||||
destroy(next)
|
||||
}
|
||||
} finally {
|
||||
destroy(printed)
|
||||
}
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
destroy(claim(out.renderer))
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])(
|
||||
"monochrome switches finish pending reasoning without repeating it (initial=%s)",
|
||||
async (mono) => {
|
||||
const out = await setup()
|
||||
const output: string[] = []
|
||||
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
|
||||
output.push(decoder.decode(event.snapshot.getRealCharBytes(true)))
|
||||
})
|
||||
try {
|
||||
await out.scrollback.setMono(mono)
|
||||
await out.scrollback.append(reasoning("Before switch"))
|
||||
await out.scrollback.setMono(!mono)
|
||||
expect(output.join("")).toContain("Before switch")
|
||||
await out.scrollback.append(reasoning(" after switch"))
|
||||
await out.scrollback.complete()
|
||||
expect(output.join("").match(/Before switch/g)).toHaveLength(1)
|
||||
expect(output.join("").match(/after switch/g)).toHaveLength(1)
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
destroy(claim(out.renderer))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("renders monochrome scrollback as ASCII markdown", async () => {
|
||||
const out = await setup({ mono: true, width: 60 })
|
||||
const output: string[] = []
|
||||
@@ -370,6 +429,22 @@ function toolCommit(input: {
|
||||
}
|
||||
}
|
||||
|
||||
test("entry looks preserve semantic colors without dimming and keep errors bold", () => {
|
||||
const theme = {
|
||||
...RUN_THEME_FALLBACK.entry,
|
||||
system: { body: "#123456" },
|
||||
reasoning: { body: "#abcdef" },
|
||||
}
|
||||
|
||||
expect(entryLook(reasoning("Thinking: next steps"), theme)).toEqual({ fg: theme.reasoning.body })
|
||||
expect(entryLook(reasoning("", "final"), theme)).toEqual({ fg: theme.system.body })
|
||||
expect(entryLook(error("failed"), theme)).toEqual({ fg: theme.error.body, attrs: TextAttributes.BOLD })
|
||||
expect(entryLook(toolCommit({ tool: "shell", phase: "final", toolState: "error" }), theme)).toEqual({
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
})
|
||||
})
|
||||
|
||||
test("scopes repeated tool part IDs to their assistant messages", () => {
|
||||
const first = toolCommit({
|
||||
tool: "read",
|
||||
|
||||
@@ -133,6 +133,50 @@ describe("run session shared", () => {
|
||||
expect(out[0]?.parts[0]).not.toBe(parts[0])
|
||||
})
|
||||
|
||||
test("keeps image-only history and preserves inline attachment metadata and skills", () => {
|
||||
const message = userMessage("msg_image", "", {
|
||||
files: [
|
||||
{
|
||||
data: "cG5n",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "Diagram",
|
||||
},
|
||||
{
|
||||
data: "c2VydmVy",
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///remote/image.png" },
|
||||
},
|
||||
],
|
||||
skills: [{ id: "effect", name: "Effect", mention: { text: "@effect", start: 0, end: 7 } }],
|
||||
})
|
||||
|
||||
expect(sessionHistory(createSession([message]))).toEqual([
|
||||
{
|
||||
text: "",
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,cG5n",
|
||||
mime: "image/png",
|
||||
filename: "image.png",
|
||||
description: "Diagram",
|
||||
source: undefined,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///remote/image.png",
|
||||
mime: "image/png",
|
||||
filename: undefined,
|
||||
source: undefined,
|
||||
},
|
||||
{ type: "skill", id: "effect", source: { value: "@effect", start: 0, end: 7 } },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("returns the latest matching variant for the active model", () => {
|
||||
const session: RunSession = {
|
||||
first: false,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, TextAttributes, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { entrySplash, entrySplashLayout, exitSplash } from "../../src/mini/splash"
|
||||
import { stringWidth } from "../../src/util/string-width"
|
||||
|
||||
const preview = "1.18.4-preview.abcd1234567890"
|
||||
const marker = "▪"
|
||||
const sessionID = "ses_fac1eb1b0ffeOk15TDttH2E1Oy"
|
||||
const theme = {
|
||||
left: RGBA.fromIndex(8, "#666666"),
|
||||
right: RGBA.defaultForeground("#cccccc"),
|
||||
leftShadow: RGBA.defaultBackground("#111111"),
|
||||
}
|
||||
|
||||
async function renderSplash(writer: ScrollbackWriter, width: number) {
|
||||
const app = await createTestRenderer({ width, height: 8, footerHeight: 4 })
|
||||
try {
|
||||
const snapshot = writer({
|
||||
width,
|
||||
widthMethod: app.renderer.widthMethod,
|
||||
tailColumn: 0,
|
||||
renderContext: app.renderer,
|
||||
})
|
||||
app.renderer.root.add(snapshot.root)
|
||||
await app.renderOnce()
|
||||
|
||||
const frame = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((row) => row.trimEnd())
|
||||
const rows = frame.slice(0, snapshot.height)
|
||||
const spans = app.captureSpans().lines.flatMap((line) => line.spans)
|
||||
expect(frame.slice(snapshot.height).every((row) => row === "")).toBe(true)
|
||||
expect(rows[0]).toBe("")
|
||||
expect(spans.every((span) => !(span.attributes & TextAttributes.DIM))).toBe(true)
|
||||
return { rows, spans }
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ width: 80, mono: false, detail: "~/src/wt/oc-mini-v2", metadata: " vlocal · ~/src/wt/oc-mini-v2" },
|
||||
{ width: 80, mono: true, detail: "~/src/wt/oc-mini-v2", metadata: " vlocal - ~/src/wt/oc-mini-v2" },
|
||||
{ width: 20, mono: false, detail: "/home/研究/長いディレクトリ/画面/設定/界e\u0301🙂", metadata: " · 界e\u0301🙂" },
|
||||
])("entry renders metadata with distinct foreground roles (%o)", async (input) => {
|
||||
const result = await renderSplash(entrySplash({ ...input, version: "local", theme }), input.width)
|
||||
const label = `${input.mono ? "[O]" : marker} oc mini`
|
||||
expect(result.rows).toEqual(["", label + input.metadata])
|
||||
const labelStyle = result.spans.find((span) => span.text === label)
|
||||
expect(labelStyle?.width).toBe(stringWidth(label))
|
||||
expect(labelStyle?.fg.intent).toBe("default")
|
||||
expect(labelStyle?.fg.toInts()).toEqual(theme.right.toInts())
|
||||
const metadata = result.spans.find((span) => span.fg.intent === "indexed")
|
||||
expect(metadata?.width).toBe(stringWidth(input.metadata))
|
||||
expect(metadata?.fg.toInts()).toEqual(theme.left.toInts())
|
||||
expect(result.spans.every((span) => !(span.attributes & TextAttributes.BOLD))).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ width: 20, mono: false, version: "local", expected: `${marker} oc mini` },
|
||||
{ width: 22, mono: false, version: "local", expected: `${marker} oc mini · oc-mini-v2` },
|
||||
{ width: 29, mono: false, version: "local", expected: `${marker} oc mini vlocal · oc-mini-v2` },
|
||||
{ width: 31, mono: false, version: "local", expected: `${marker} oc mini vlocal · …/oc-mini-v2` },
|
||||
{ width: 20, mono: false, version: preview, expected: `${marker} oc mini` },
|
||||
{ width: 24, mono: false, version: preview, expected: `${marker} oc mini · oc-mini-v2` },
|
||||
{ width: 24, mono: true, version: preview, expected: "[O] oc mini - oc-mini-v2" },
|
||||
{ width: 32, mono: false, version: preview, expected: `${marker} oc mini · oc-mini-v2` },
|
||||
])("entry progressively admits the basename, whole version, and parent directories (%o)", (input) => {
|
||||
const layout = entrySplashLayout({ ...input, detail: "~/src/wt/oc-mini-v2" })
|
||||
expect(layout.label + layout.metadata).toBe(input.expected)
|
||||
})
|
||||
|
||||
test.each([undefined, "", "/", "~/", "C:\\projects\\mini\\"])(
|
||||
"entry handles absent and root locations (%s)",
|
||||
(detail) => {
|
||||
const layout = entrySplashLayout({ width: 80, version: "local", detail })
|
||||
expect(layout.metadata).toBe(" vlocal" + (detail ? ` · ${detail}` : ""))
|
||||
},
|
||||
)
|
||||
|
||||
test.each([false, true])("entry layout preserves admitted information at every width (mono=%s)", (mono) => {
|
||||
const suffix = (value: string) =>
|
||||
value
|
||||
.replace(/^(?:…|\.\.\.)[/\\]/, "")
|
||||
.replace(/[\\/]+/g, "/")
|
||||
.replace(/\/$/, "")
|
||||
for (const detail of [
|
||||
undefined,
|
||||
"",
|
||||
"/",
|
||||
"~/",
|
||||
"x/y",
|
||||
"a/b/c",
|
||||
"~/src/wt/oc-mini-v2",
|
||||
"C:\\projects\\mini\\",
|
||||
"/home/研究/画面/界e\u0301🙂",
|
||||
"~/project-directory-that-cannot-meaningfully-fit",
|
||||
]) {
|
||||
for (const version of ["", "local", preview]) {
|
||||
let previous = entrySplashLayout({ width: 0, detail, version, mono })
|
||||
for (let width = 1; width <= 160; width++) {
|
||||
const layout = entrySplashLayout({ width, detail, version, mono })
|
||||
expect(stringWidth(layout.label + layout.metadata)).toBeLessThanOrEqual(width)
|
||||
expect(layout.version === "" || layout.version === version).toBe(true)
|
||||
if (previous.version) expect(layout.version).toBe(previous.version)
|
||||
if (previous.path) {
|
||||
expect(layout.path).not.toBe("")
|
||||
expect(suffix(layout.path)).toEndWith(suffix(previous.path))
|
||||
}
|
||||
if (layout.version && detail) expect(layout.path).not.toBe("")
|
||||
if (layout.path) expect(suffix(detail!)).toEndWith(suffix(layout.path))
|
||||
const marked = `${mono ? "[O]" : marker} oc mini`
|
||||
expect(layout.label).toBe(stringWidth(marked) <= width ? marked : "oc mini".slice(0, width))
|
||||
previous = layout
|
||||
}
|
||||
expect(previous.path).toBe(detail ?? "")
|
||||
expect(previous.version).toBe(version)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("entry skips abbreviated paths that are longer than the full path (mono=%s)", (mono) => {
|
||||
const label = `${mono ? "[O]" : marker} oc mini`
|
||||
const metadata = mono ? " vlocal - a/b/c" : " vlocal · a/b/c"
|
||||
expect(
|
||||
entrySplashLayout({ width: stringWidth(label + metadata), version: "local", detail: "a/b/c", mono }),
|
||||
).toMatchObject({ label, metadata, path: "a/b/c" })
|
||||
})
|
||||
|
||||
test.each(["entry", "exit"])("%s commits one scrollback snapshot without reflow on resize", async (kind) => {
|
||||
const writer =
|
||||
kind === "entry"
|
||||
? entrySplash({ version: "local", detail: "~/src/wt/oc-mini-v2", theme })
|
||||
: exitSplash({ title: "Review mini layout", session_id: sessionID, theme })
|
||||
const result = await renderSplash(writer, 24)
|
||||
const app = await createTestRenderer({
|
||||
width: 24,
|
||||
height: 8,
|
||||
footerHeight: 4,
|
||||
screenMode: "split-footer",
|
||||
externalOutputMode: "capture-stdout",
|
||||
})
|
||||
try {
|
||||
app.renderer.writeToScrollback(writer)
|
||||
await app.renderOnce()
|
||||
expect(app.externalOutput.take()).toMatchObject([
|
||||
{
|
||||
width: 24,
|
||||
height: result.rows.length,
|
||||
rowColumns: 24,
|
||||
rows: result.rows,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
},
|
||||
])
|
||||
app.resize(112, 8)
|
||||
await app.renderOnce()
|
||||
expect(app.externalOutput.take()).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(
|
||||
[
|
||||
{ width: 16, showSession: true },
|
||||
{ width: 56, showSession: true },
|
||||
{ width: 80, showSession: true },
|
||||
{ width: 80, showSession: false },
|
||||
].flatMap((size) => [false, true].map((mono) => ({ ...size, mono }))),
|
||||
)("exit retains the complete resume command (%o)", async (input) => {
|
||||
const result = await renderSplash(
|
||||
exitSplash({ ...input, title: "Review mini layout", session_id: sessionID, theme }),
|
||||
input.width,
|
||||
)
|
||||
const command = `opencode mini -s ${sessionID}`
|
||||
const commandRows =
|
||||
input.width >= 80 ? [result.rows[2].slice(result.rows[2].indexOf("opencode"))] : result.rows.slice(1)
|
||||
const reconstructed = commandRows
|
||||
.map((row, index) => (index < commandRows.length - 1 ? row.padEnd(input.width) : row))
|
||||
.join("")
|
||||
expect(reconstructed).toBe(command)
|
||||
if (input.width >= 80) {
|
||||
expect(result.rows[1].startsWith(input.mono ? "[O]" : "█▀▀█")).toBe(true)
|
||||
expect(result.rows[1].includes("Session Review mini layout")).toBe(input.showSession)
|
||||
expect(result.rows[2]).toContain("Continue " + command)
|
||||
} else {
|
||||
expect(result.rows.join("\n")).not.toMatch(/Session|Continue|Review mini layout|█|\[O\]/)
|
||||
expect(result.rows).toHaveLength(1 + Math.ceil(command.length / input.width))
|
||||
}
|
||||
if (input.mono) expect(result.rows.join("")).not.toMatch(/[^\x20-\x7e]/)
|
||||
expect(result.spans.find((span) => span.text.includes("opencode"))?.fg.intent).toBe("default")
|
||||
expect(result.spans.find((span) => span.text.includes("opencode"))?.fg.toInts()).toEqual(theme.right.toInts())
|
||||
})
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type MessageListOutput,
|
||||
type OpenCodeClient,
|
||||
type PermissionRequest,
|
||||
type SessionInboxInfo,
|
||||
type ToolContent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
||||
import { entryBody } from "../../src/mini/entry.body"
|
||||
@@ -98,6 +100,15 @@ function footer() {
|
||||
|
||||
type SessionMessages = MessageListOutput["data"]
|
||||
|
||||
const image = {
|
||||
data: "cG5n",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "Diagram",
|
||||
mention: { start: 5, end: 14, text: "[Image 1]" },
|
||||
} as const
|
||||
|
||||
function compaction(status: "running" | "completed", summary: string): SessionMessages[number] {
|
||||
const message = {
|
||||
id: "msg_compaction",
|
||||
@@ -217,6 +228,175 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("V2 mini transport", () => {
|
||||
test.each(["before", "after"])("renders materialized prompt images when delivery is %s the ack", async (order) => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const requested = defer()
|
||||
const ack = defer()
|
||||
const admitted = defer()
|
||||
const idle = defer()
|
||||
const messages: SessionMessages = []
|
||||
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
|
||||
const ui = footer()
|
||||
const live: StreamCommit[] = []
|
||||
const files = [
|
||||
{ ...image, data: "c2VydmVy", name: "remote.png", source: { type: "uri" as const, uri: "file:///remote.png" } },
|
||||
image,
|
||||
]
|
||||
const pending = {
|
||||
id: "msg_prompt",
|
||||
sessionID: "ses_1",
|
||||
type: "user",
|
||||
payload: { text: "look [Image 1]", files },
|
||||
delivery: "steer",
|
||||
timeCreated: 1,
|
||||
} satisfies SessionInboxInfo
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(() => {
|
||||
requested.resolve()
|
||||
return ack.promise.then(() => pending) as never
|
||||
})
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
replay: true,
|
||||
footer: ui.api,
|
||||
onCommit: (commit) => live.push(commit),
|
||||
})
|
||||
ui.api.append({ kind: "user", source: "system", text: pending.payload.text, messageID: pending.id, phase: "start" })
|
||||
const turn = transport.runPromptTurn(
|
||||
{
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: {
|
||||
messageID: pending.id,
|
||||
text: pending.payload.text,
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,cG5n",
|
||||
filename: image.name,
|
||||
mime: image.mime,
|
||||
description: image.description,
|
||||
source: { type: "file", text: { start: 5, end: 14, value: "[Image 1]" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
files: [{ type: "file", url: "file:///remote.png", filename: "remote.png", mime: "image/png" }],
|
||||
includeFiles: true,
|
||||
},
|
||||
admitted.resolve,
|
||||
)
|
||||
await requested.promise
|
||||
if (order === "after") {
|
||||
ack.resolve()
|
||||
await admitted.promise
|
||||
}
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual([])
|
||||
events.push({
|
||||
id: "evt_delivered",
|
||||
created: 1,
|
||||
type: "session.inbox.delivered",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1", inboxID: pending.id },
|
||||
})
|
||||
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "waiting for assistant"))
|
||||
await Bun.sleep(0)
|
||||
ack.resolve()
|
||||
await admitted.promise
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: [
|
||||
{ uri: "file:///remote.png", name: "remote.png" },
|
||||
{
|
||||
uri: "data:image/png;base64,cG5n",
|
||||
name: image.name,
|
||||
description: image.description,
|
||||
mention: image.mention,
|
||||
},
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
const images = ui.commits.filter((commit) => commit.image)
|
||||
expect(images).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: "remote.png",
|
||||
image: "data:image/png;base64,c2VydmVy",
|
||||
messageID: pending.id,
|
||||
partID: "image:0",
|
||||
phase: "final",
|
||||
},
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: image.name,
|
||||
image: "data:image/png;base64,cG5n",
|
||||
messageID: pending.id,
|
||||
partID: "image:1",
|
||||
phase: "final",
|
||||
},
|
||||
])
|
||||
messages.push({ id: pending.id, type: "user", ...pending.payload, time: { created: 1 } })
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
|
||||
expect(ui.commits.filter((commit) => commit.kind === "user" && !commit.image)).toHaveLength(1)
|
||||
await transport.replayOnResize({
|
||||
localRows: () => live.map((commit) => ({ commit })),
|
||||
reset: async () => {
|
||||
ui.commits.length = 0
|
||||
},
|
||||
})
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test.each([true, false])(
|
||||
"preserves visible image identity and suppresses unreplayed images with replay=%s",
|
||||
async (replay) => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
streams: [events],
|
||||
messages: {
|
||||
ses_1: [
|
||||
{
|
||||
id: "msg_images",
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [
|
||||
image,
|
||||
image,
|
||||
{ ...image, description: "Another diagram" },
|
||||
{ ...image, source: { type: "uri", uri: "file:///image.png" } },
|
||||
],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
replay,
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
await transport.waitForIdle()
|
||||
expect(ui.commits).toHaveLength(replay ? 3 : 0)
|
||||
expect(ui.commits.every((commit) => commit.image === "data:image/png;base64,cG5n")).toBe(true)
|
||||
expect(new Set(ui.commits.map((commit) => commit.partID)).size).toBe(replay ? 3 : 0)
|
||||
await transport.close()
|
||||
},
|
||||
)
|
||||
|
||||
test("renders projected compactions as labeled transcript boundaries", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
@@ -307,7 +487,7 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("formats footer usage with compact tokens and context percentage", async () => {
|
||||
test("preserves numeric footer tokens, context percentage, and cost-only usage", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const ui = footer()
|
||||
@@ -346,7 +526,26 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
|
||||
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage)) await Bun.sleep(0)
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: "7.5K (5%)" } })
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: { tokens: 7_508, percent: 5 } } })
|
||||
|
||||
events.push({
|
||||
id: "evt_cost_only",
|
||||
created: 3,
|
||||
type: "session.step.ended",
|
||||
durable: durable("ses_1", 3),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_cost_only",
|
||||
finish: "stop",
|
||||
cost: 0.1234,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage?.cost)) await Bun.sleep(0)
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.patch",
|
||||
patch: { usage: { tokens: 0, percent: undefined, cost: 0.1234 } },
|
||||
})
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -669,6 +868,7 @@ describe("V2 mini transport", () => {
|
||||
type: "user",
|
||||
payload: {
|
||||
text: "follow up",
|
||||
files: [image],
|
||||
skills: [
|
||||
{ id: "effect", name: "Effect", text: "Use Effect services" },
|
||||
{ id: "effect", name: "Effect" },
|
||||
@@ -681,7 +881,7 @@ describe("V2 mini transport", () => {
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
payload: { text: "remove me" },
|
||||
payload: { text: "remove me", files: [image] },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
@@ -717,6 +917,7 @@ describe("V2 mini transport", () => {
|
||||
expect.objectContaining({ kind: "user", text: "follow up" }),
|
||||
])
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
expect(ui.commits.some((commit) => commit.image)).toBe(false)
|
||||
events.push({
|
||||
id: "evt_queued",
|
||||
created: 4,
|
||||
@@ -746,7 +947,10 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1", inboxID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 0) await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(3)
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual([
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", image: "data:image/png;base64,cG5n" }),
|
||||
])
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
@@ -1277,10 +1481,12 @@ describe("V2 mini transport", () => {
|
||||
const second = feed()
|
||||
first.push(connected("evt_connected_1"))
|
||||
second.push(connected("evt_connected_2"))
|
||||
const idle = defer()
|
||||
let running = true
|
||||
let projected = false
|
||||
const client = sdk({
|
||||
streams: [first, second],
|
||||
wait: () => idle.promise,
|
||||
active: () => {
|
||||
const active: Record<string, { type: "running" }> = {}
|
||||
if (running) active.ses_1 = { type: "running" }
|
||||
@@ -1295,7 +1501,7 @@ describe("V2 mini transport", () => {
|
||||
id: "msg_prompt",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
files: [],
|
||||
files: [image],
|
||||
agents: [],
|
||||
time: { created: 2 },
|
||||
},
|
||||
@@ -1332,9 +1538,16 @@ describe("V2 mini transport", () => {
|
||||
projected = true
|
||||
running = false
|
||||
first.close()
|
||||
while (!ui.commits.some((commit) => commit.image)) await Bun.sleep(0)
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(ui.commits.filter((item) => item.kind === "user" && item.messageID === "msg_prompt")).toHaveLength(1)
|
||||
expect(
|
||||
ui.commits.filter((item) => item.kind === "user" && !item.image && item.messageID === "msg_prompt"),
|
||||
).toHaveLength(1)
|
||||
expect(ui.commits.filter((item) => item.image)).toEqual([
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_prompt", image: "data:image/png;base64,cG5n" }),
|
||||
])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -2209,6 +2422,114 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test.each(["completed", "error"] as const)(
|
||||
"renders only terminal inline tool images for %s results across resize",
|
||||
async (status) => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const messages: SessionMessages = []
|
||||
const client = sdk({ streams: [events], messages: { ses_1: messages } })
|
||||
const ui = footer()
|
||||
const live: StreamCommit[] = []
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
replay: true,
|
||||
footer: ui.api,
|
||||
onCommit: (commit) => live.push(commit),
|
||||
})
|
||||
const content = [
|
||||
{ type: "text", text: "Captured screenshot" },
|
||||
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
|
||||
{ type: "file", mime: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,dGV4dA==" },
|
||||
{ type: "file", mime: "application/pdf", uri: "data:image/png;base64,cG5n" },
|
||||
] satisfies [ToolContent, ...ToolContent[]]
|
||||
const error = { type: "unknown", message: "Tool failed after capturing image" } as const
|
||||
const data = { sessionID: "ses_1", assistantMessageID: "msg_tool", id: "call_image", executed: true, content }
|
||||
const terminal: RunV2Event =
|
||||
status === "completed"
|
||||
? { id: "evt_result", created: 3, type: "session.tool.success", durable: durable("ses_1", 3, 2), data }
|
||||
: {
|
||||
id: "evt_result",
|
||||
created: 3,
|
||||
type: "session.tool.failed",
|
||||
durable: durable("ses_1", 3, 2),
|
||||
data: { ...data, error },
|
||||
}
|
||||
events.push({
|
||||
id: "evt_input",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable("ses_1", 1),
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_tool", id: "call_image", name: "read" },
|
||||
})
|
||||
events.push({
|
||||
id: "evt_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: durable("ses_1", 2),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_tool",
|
||||
id: "call_image",
|
||||
input: { file_path: "image.png" },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
while (!ui.commits.some((commit) => commit.toolState === "running")) await Bun.sleep(0)
|
||||
expect(ui.commits.some((commit) => commit.image)).toBe(false)
|
||||
events.push(terminal)
|
||||
events.push(terminal)
|
||||
while (!ui.commits.some((commit) => commit.image)) await Bun.sleep(0)
|
||||
const images = ui.commits.filter((commit) => commit.image)
|
||||
expect(images).toEqual([
|
||||
{
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
text: "[Image 1]",
|
||||
image: "data:image/png;base64,cG5n",
|
||||
messageID: "msg_tool",
|
||||
partID: "prt_call_image:image:0",
|
||||
phase: "final",
|
||||
},
|
||||
])
|
||||
|
||||
const message = {
|
||||
id: "msg_tool",
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
canonicalToolPart(
|
||||
"read",
|
||||
status === "completed" ? { status, input: {}, content } : { status, input: {}, content, error },
|
||||
"call_image",
|
||||
),
|
||||
],
|
||||
time: { created: 1 },
|
||||
}
|
||||
messages.push({ ...message, content: [] })
|
||||
await transport.replayOnResize({
|
||||
localRows: () => live.map((commit) => ({ commit })),
|
||||
reset: async () => {
|
||||
ui.commits.length = 0
|
||||
},
|
||||
})
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
|
||||
messages[0] = message
|
||||
await transport.replayOnResize({
|
||||
localRows: () => live.map((commit) => ({ commit })),
|
||||
reset: async () => {
|
||||
ui.commits.length = 0
|
||||
},
|
||||
})
|
||||
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
|
||||
await transport.close()
|
||||
},
|
||||
)
|
||||
|
||||
test("waits for the attempted web search provider before rendering its title", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
@@ -3300,7 +3621,10 @@ describe("V2 mini transport", () => {
|
||||
id: "call_child_shell",
|
||||
error: { type: "unknown", message: "child boom" },
|
||||
metadata: { checkpoint: "child" },
|
||||
content: [{ type: "text", text: "child partial" }],
|
||||
content: [
|
||||
{ type: "text", text: "child partial" },
|
||||
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
|
||||
],
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
@@ -3327,8 +3651,19 @@ describe("V2 mini transport", () => {
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
metadata: { checkpoint: "child" },
|
||||
content: [{ type: "text", text: "child partial" }],
|
||||
content: [
|
||||
{ type: "text", text: "child partial" },
|
||||
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
|
||||
],
|
||||
})
|
||||
expect(commits.filter((commit) => commit.image)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
messageID: "msg_child_tool",
|
||||
image: "data:image/png;base64,cG5n",
|
||||
phase: "final",
|
||||
}),
|
||||
])
|
||||
expect(
|
||||
ui.events.find(
|
||||
(event) =>
|
||||
@@ -3491,7 +3826,7 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
inboxID: "msg_child_prompt",
|
||||
item: { type: "user", payload: { text: "actual child prompt" }, delivery: "steer" },
|
||||
item: { type: "user", payload: { text: "actual child prompt", files: [image] }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
@@ -3517,6 +3852,84 @@ describe("V2 mini transport", () => {
|
||||
)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(
|
||||
states()
|
||||
.at(-1)
|
||||
?.details.ses_child?.commits.filter((commit) => commit.image),
|
||||
).toEqual([
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_child_prompt", image: "data:image/png;base64,cG5n" }),
|
||||
])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("hydrates child images and keeps pending attachments hidden until delivery", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
streams: [events],
|
||||
sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }],
|
||||
messages: {
|
||||
ses_child: [
|
||||
{ id: "msg_old_image", type: "user", text: "", files: [image, image], time: { created: 1 } },
|
||||
{
|
||||
id: "msg_child_tool",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
canonicalToolPart("read", {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" }],
|
||||
}),
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
pending: {
|
||||
ses_child: [
|
||||
{
|
||||
id: "msg_pending_image",
|
||||
sessionID: "ses_child",
|
||||
type: "user",
|
||||
payload: { text: "", files: [image] },
|
||||
delivery: "queue",
|
||||
timeCreated: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
const images = () =>
|
||||
ui.events
|
||||
.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
|
||||
.at(-1)
|
||||
?.details.ses_child?.commits.filter((commit) => commit.image) ?? []
|
||||
transport.selectSubagent("ses_child")
|
||||
while (images().length < 2) await Bun.sleep(0)
|
||||
expect(
|
||||
images()
|
||||
.map((commit) => commit.kind)
|
||||
.sort(),
|
||||
).toEqual(["tool", "user"])
|
||||
expect(images().some((commit) => commit.messageID === "msg_pending_image")).toBe(false)
|
||||
const delivered: RunV2Event = {
|
||||
id: "evt_child_image_delivered",
|
||||
created: 3,
|
||||
type: "session.inbox.delivered",
|
||||
durable: durable("ses_child", 3),
|
||||
data: { sessionID: "ses_child", inboxID: "msg_pending_image" },
|
||||
}
|
||||
events.push(delivered)
|
||||
events.push(delivered)
|
||||
while (images().length < 3) await Bun.sleep(0)
|
||||
expect(images().filter((commit) => commit.messageID === "msg_pending_image")).toHaveLength(1)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -3555,7 +3968,11 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
inboxID: "msg_child_race",
|
||||
item: { type: "user", payload: { text: "prompt admitted before hydration" }, delivery: "steer" },
|
||||
item: {
|
||||
type: "user",
|
||||
payload: { text: "prompt admitted before hydration", files: [image] },
|
||||
delivery: "steer",
|
||||
},
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
@@ -3581,6 +3998,13 @@ describe("V2 mini transport", () => {
|
||||
)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(
|
||||
states()
|
||||
.at(-1)
|
||||
?.details.ses_child?.commits.filter((commit) => commit.image),
|
||||
).toEqual([
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_child_race", image: "data:image/png;base64,cG5n" }),
|
||||
])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES } from "../../src/theme"
|
||||
import { DEFAULT_THEME, resolveThemeDocument, type ResolvedTheme } from "@opencode-ai/theme/tui"
|
||||
import {
|
||||
RUN_THEME_MONO,
|
||||
RUN_THEME_FALLBACK,
|
||||
RUN_THEME_FALLBACK_LIGHT,
|
||||
resolveRunTheme,
|
||||
type RunTheme,
|
||||
} from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES, parseTheme } from "../../src/theme"
|
||||
import { generateSystem } from "../../src/theme/system"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const tmp = await tmpdir()
|
||||
const previousConfig = process.env.OPENCODE_CONFIG_DIR
|
||||
beforeAll(() => {
|
||||
process.env.OPENCODE_CONFIG_DIR = tmp.path
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (previousConfig === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||
else process.env.OPENCODE_CONFIG_DIR = previousConfig
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
})
|
||||
|
||||
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
|
||||
|
||||
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
|
||||
function terminalColors(input: Partial<TerminalColors> = {}, mode: "light" | "dark" = "dark"): TerminalColors {
|
||||
return {
|
||||
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
|
||||
defaultBackground: input.defaultBackground ?? "#1a1b26",
|
||||
defaultForeground: input.defaultForeground ?? "#c0caf5",
|
||||
cursorColor: input.cursorColor ?? "#ff9e64",
|
||||
mouseForeground: input.mouseForeground ?? null,
|
||||
mouseBackground: input.mouseBackground ?? null,
|
||||
tekForeground: input.tekForeground ?? null,
|
||||
tekBackground: input.tekBackground ?? null,
|
||||
highlightBackground: input.highlightBackground ?? "#33467c",
|
||||
highlightForeground: input.highlightForeground ?? "#c0caf5",
|
||||
palette: Array.from({ length: 256 }, (_, index) => palette[index % palette.length]!),
|
||||
defaultBackground: mode === "light" ? "#fbf1c7" : "#1a1b26",
|
||||
defaultForeground: mode === "light" ? "#3c3836" : "#c0caf5",
|
||||
cursorColor: "#ff9e64",
|
||||
mouseForeground: null,
|
||||
mouseBackground: null,
|
||||
tekForeground: null,
|
||||
tekBackground: null,
|
||||
highlightBackground: "#33467c",
|
||||
highlightForeground: "#c0caf5",
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
const emptyColors = terminalColors({
|
||||
palette: Array.from({ length: 256 }, () => null),
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
cursorColor: null,
|
||||
highlightBackground: null,
|
||||
highlightForeground: null,
|
||||
})
|
||||
|
||||
function renderer(
|
||||
input: {
|
||||
themeMode?: "dark" | "light"
|
||||
@@ -29,166 +60,337 @@ function renderer(
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
themeMode: input.themeMode,
|
||||
get themeMode() {
|
||||
return input.themeMode
|
||||
},
|
||||
waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null,
|
||||
getPalette: async () => {
|
||||
if (input.fail) {
|
||||
throw new Error("boom")
|
||||
}
|
||||
|
||||
if (input.fail) throw new Error("boom")
|
||||
return input.colors ?? terminalColors()
|
||||
},
|
||||
} as CliRenderer
|
||||
}
|
||||
|
||||
function expectRgba(color: unknown) {
|
||||
function rgba(color: unknown) {
|
||||
expect(color).toBeInstanceOf(RGBA)
|
||||
if (!(color instanceof RGBA)) {
|
||||
throw new Error("expected RGBA")
|
||||
}
|
||||
|
||||
if (!(color instanceof RGBA)) throw new Error("expected RGBA")
|
||||
return color
|
||||
}
|
||||
|
||||
function expectIndexed(color: unknown) {
|
||||
const rgba = expectRgba(color)
|
||||
expect(rgba.intent).toBe("indexed")
|
||||
expect(rgba.slot).toBeLessThan(256)
|
||||
function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
|
||||
const expected = {
|
||||
text: theme.text.default,
|
||||
muted: theme.text.subdued,
|
||||
warning: theme.text.feedback.warning.default,
|
||||
error: theme.text.feedback.error.default,
|
||||
actionSecondaryText: theme.contextual.elevated.text.action.secondary.default,
|
||||
actionFocusedBg: theme.contextual.elevated.background.action.primary.focused,
|
||||
actionFocusedText: theme.contextual.elevated.text.action.primary.focused,
|
||||
formfieldText: theme.contextual.elevated.text.formfield.default,
|
||||
formfieldFocusedBg: theme.contextual.elevated.background.formfield.focused,
|
||||
formfieldFocusedText: theme.contextual.elevated.text.formfield.focused,
|
||||
selection: theme.contextual.elevated.text.formfield.selected,
|
||||
variant: theme.text.subdued,
|
||||
running: theme.text.status.running,
|
||||
question: theme.text.status.question,
|
||||
permission: theme.text.status.permission,
|
||||
success: theme.text.feedback.success.default,
|
||||
link: theme.markdown.link,
|
||||
statusAccent: theme.background.surface.overlay,
|
||||
shade: theme.contextual.elevated.background.default,
|
||||
surface: theme.contextual.elevated.background.default,
|
||||
pane: theme.contextual.overlay.background.default,
|
||||
border: theme.border.default,
|
||||
line: theme.background.surface.overlay,
|
||||
}
|
||||
Object.entries(expected).forEach(([key, color]) => {
|
||||
expect(rgba(actual.footer[key as keyof typeof expected]).toInts()).toEqual(color.toInts())
|
||||
})
|
||||
expect(rgba(actual.background).intent).toBe("default")
|
||||
}
|
||||
|
||||
function spread(color: RGBA) {
|
||||
const [r, g, b] = color.toInts()
|
||||
return Math.max(r, g, b) - Math.min(r, g, b)
|
||||
}
|
||||
|
||||
test("falls back when palette lookup fails", async () => {
|
||||
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
|
||||
expect(await resolveRunTheme(renderer({ fail: true }), undefined, true)).toBe(RUN_THEME_MONO)
|
||||
const light = await resolveRunTheme(renderer({ resolvedThemeMode: "light" }), undefined, true)
|
||||
expect(expectRgba(light.footer.text).toInts().slice(0, 3)).toEqual([0, 0, 0])
|
||||
expect(RUN_THEME_MONO.block.syntax).toBeUndefined()
|
||||
test.each(["light", "dark"] as const)("preserves %s monochrome terminal defaults", async (mode) => {
|
||||
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "unknown", mode }, true)
|
||||
expect(theme.block.syntax).toBeUndefined()
|
||||
expect(rgba(theme.footer.text).toInts().slice(0, 3)).toEqual(mode === "light" ? [0, 0, 0] : [255, 255, 255])
|
||||
for (const color of [
|
||||
RUN_THEME_MONO.background,
|
||||
...Object.values(RUN_THEME_MONO.footer),
|
||||
...Object.values(RUN_THEME_MONO.splash),
|
||||
...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
|
||||
...Object.entries(RUN_THEME_MONO.block)
|
||||
.filter(([key]) => key !== "syntax")
|
||||
.map(([, value]) => value),
|
||||
theme.background,
|
||||
...Object.values(theme.footer).flat(),
|
||||
...Object.values(theme.splash),
|
||||
...Object.values(theme.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
|
||||
...Object.values(theme.block),
|
||||
]) {
|
||||
expect(expectRgba(color).intent).toBe("default")
|
||||
expect(rgba(color).intent).toBe("default")
|
||||
}
|
||||
if (mode === "dark") expect(await resolveRunTheme(renderer(), undefined, true)).toBe(RUN_THEME_MONO)
|
||||
expect(await resolveRunTheme(renderer({ fail: true, resolvedThemeMode: mode }), undefined, true)).toBe(theme)
|
||||
const term = renderer({ themeMode: mode })
|
||||
let queries = 0
|
||||
term.getPalette = async () => {
|
||||
queries++
|
||||
return terminalColors()
|
||||
}
|
||||
expect(await resolveRunTheme(term, { mode: mode === "light" ? "dark" : "light" }, true)).toBe(theme)
|
||||
expect(queries).toBe(0)
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("uses shared %s defaults and named built-in themes", async (mode) => {
|
||||
const colors = terminalColors({}, mode)
|
||||
for (const name of [undefined, "opencode", "tokyonight"] as const) {
|
||||
const theme = await resolveRunTheme(renderer({ colors }), { name, mode })
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES[name ?? "opencode"]), mode)
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
|
||||
expect(theme.footer.categorical.map((color) => rgba(color).toInts())).toEqual(
|
||||
expected.categorical.map((scale) => scale[mode === "light" ? 800 : 200].toInts()),
|
||||
)
|
||||
expect(theme.block.syntax?.getAllStyles().size).toBeGreaterThan(0)
|
||||
for (const color of [
|
||||
theme.entry.user.body,
|
||||
theme.entry.assistant.body,
|
||||
theme.block.text,
|
||||
...Object.values(theme.splash),
|
||||
]) {
|
||||
expect(rgba(color).intent).toBe("indexed")
|
||||
expect(rgba(color).slot).toBeLessThan(256)
|
||||
}
|
||||
expect(rgba(theme.footer.text).intent).toBe("rgb")
|
||||
for (const style of theme.block.syntax!.getAllStyles().values()) {
|
||||
if (style.fg && style.fg.a !== 0) expect(style.fg.intent).toBe("indexed")
|
||||
if (style.bg && style.bg.a !== 0) expect(style.bg.intent).toBe("indexed")
|
||||
}
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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" }))
|
||||
|
||||
test.each(["light", "dark"] as const)("shares the %s system scheme while retaining terminal intent", async (mode) => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: mode === "light" ? "#fbf1c7" : "#0f172a",
|
||||
defaultForeground: mode === "light" ? "#3c3836" : "#e2e8f0",
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer({ colors }), { name: "system", mode })
|
||||
try {
|
||||
expect(theme.block.syntax).toBeDefined()
|
||||
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expectIndexed(theme.splash.left)
|
||||
expectIndexed(theme.splash.right)
|
||||
expectIndexed(theme.splash.leftShadow)
|
||||
expectRgba(theme.footer.highlight)
|
||||
expectRgba(theme.footer.statusAccent)
|
||||
expectRgba(theme.footer.surface)
|
||||
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, mode)), mode))
|
||||
expect(rgba(theme.footer.text).intent).toBe("default")
|
||||
expect(rgba(theme.entry.user.body).intent).toBe("default")
|
||||
expect(rgba(theme.entry.assistant.body).intent).toBe("default")
|
||||
expect(theme.block.syntax?.getStyle("default")?.fg?.intent).toBe("default")
|
||||
expect(rgba(theme.footer.surface).intent).toBe("rgb")
|
||||
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
|
||||
Object.values(theme.splash).forEach((color) => expect(rgba(color).intent).toBe("indexed"))
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: "#0f172a",
|
||||
defaultForeground: "#e2e8f0",
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
|
||||
const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
|
||||
test.each(["light", "dark"] as const)(
|
||||
"loads %s custom files with shared partial and standalone fallback",
|
||||
async (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const source = {
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: DEFAULT_THEME[mode].hue,
|
||||
text: {
|
||||
default: "#123456",
|
||||
formfield: { default: "#234567", $selected: "#345678" },
|
||||
feedback: { warning: { default: "#456789" } },
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { $focused: "#56789a" } }, formfield: { $focused: "#6789ab" } },
|
||||
background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), { name: "mini-custom", mode })
|
||||
try {
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(source), mode))
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["light", "dark"] as const)(
|
||||
"falls back to shared %s defaults for unknown or invalid themes",
|
||||
async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
for (const source of [
|
||||
{ version: 2, [mode]: { categorical: [] } },
|
||||
{ version: 2, [mode]: { text: { default: "$missing" } } },
|
||||
undefined,
|
||||
]) {
|
||||
if (source) await Bun.write(path.join(tmp.path, "themes", "mini-invalid.json"), JSON.stringify(source))
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), {
|
||||
name: source ? "mini-invalid" : "mini-unknown",
|
||||
mode,
|
||||
})
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %s terminal", async (mode) => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: mode === "light" ? "#ffffff" : "#0f0f0f",
|
||||
defaultForeground: mode === "light" ? "#000000" : "#edecee",
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer({ colors }), { name: "aura" })
|
||||
try {
|
||||
expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
|
||||
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
|
||||
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
|
||||
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(DEFAULT_THEMES[mode === "light" ? "opencode" : "aura"]), mode))
|
||||
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("uses refreshed background brightness when cached renderer mode is stale", async () => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: "#fbf1c7",
|
||||
defaultForeground: "#3c3836",
|
||||
})
|
||||
const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
|
||||
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
|
||||
test.each(["light", "dark"] as const)(
|
||||
"falls back only for unsupported modes of a %s-only custom theme",
|
||||
async (mode) => {
|
||||
const source = { version: 2, [mode]: { text: { default: "#123456" } } }
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-one-mode.json"), JSON.stringify(source))
|
||||
for (const requested of ["light", "dark"] as const) {
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, requested) }), {
|
||||
name: "mini-one-mode",
|
||||
mode: requested,
|
||||
})
|
||||
try {
|
||||
expectFooter(
|
||||
theme,
|
||||
resolveThemeDocument(parseTheme(requested === mode ? source : DEFAULT_THEMES.opencode), requested),
|
||||
)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["light", "dark"] as const)("handles unavailable palettes in %s mode", async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
for (const input of [
|
||||
{ fail: true },
|
||||
{ colors: emptyColors },
|
||||
{ colors: { ...emptyColors, defaultBackground: terminalColors({}, mode).defaultBackground } },
|
||||
{ colors: { ...emptyColors, defaultForeground: terminalColors({}, mode).defaultForeground } },
|
||||
]) {
|
||||
const fallback = await resolveRunTheme(renderer(input), { name: "system", mode })
|
||||
expect(fallback).toBe(mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK)
|
||||
expectFooter(fallback, expected)
|
||||
}
|
||||
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "opencode", mode })
|
||||
try {
|
||||
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
|
||||
expectFooter(theme, expected)
|
||||
} finally {
|
||||
stale.block.syntax?.destroy()
|
||||
light.block.syntax?.destroy()
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps renderer mode when refreshed default background is unavailable", async () => {
|
||||
const colors = {
|
||||
...terminalColors(),
|
||||
defaultBackground: null,
|
||||
palette: ["#000000", ...terminalColors().palette.slice(1)],
|
||||
}
|
||||
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
|
||||
const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
|
||||
|
||||
try {
|
||||
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
|
||||
} finally {
|
||||
light.block.syntax?.destroy()
|
||||
dark.block.syntax?.destroy()
|
||||
test("uses refreshed background brightness rather than stale mode or ANSI slot zero", async () => {
|
||||
for (const colors of [
|
||||
terminalColors({}, "light"),
|
||||
terminalColors({ defaultBackground: null, palette: ["#000000", ...terminalColors().palette.slice(1)] }),
|
||||
]) {
|
||||
const theme = await resolveRunTheme(renderer({ themeMode: colors.defaultBackground ? "dark" : "light", colors }), {
|
||||
name: "system",
|
||||
})
|
||||
try {
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, "light")), "light"))
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps dark surfaces neutral on saturated backgrounds", () => {
|
||||
const theme = resolveTheme(
|
||||
generateSystem(
|
||||
terminalColors({
|
||||
defaultBackground: "#0000ff",
|
||||
defaultForeground: "#ffffff",
|
||||
}),
|
||||
"dark",
|
||||
),
|
||||
"dark",
|
||||
)
|
||||
|
||||
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
|
||||
expect(spread(theme.backgroundElement)).toBeLessThan(10)
|
||||
test.each(["light", "dark"] as const)("follows physical %s mode over opposite configuration", async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
for (const fail of [false, true]) {
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode), themeMode: mode, fail }), {
|
||||
mode: mode === "light" ? "dark" : "light",
|
||||
})
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps light surfaces close to neutral on warm backgrounds", () => {
|
||||
const theme = resolveTheme(
|
||||
generateSystem(
|
||||
terminalColors({
|
||||
defaultBackground: "#fbf1c7",
|
||||
defaultForeground: "#3c3836",
|
||||
}),
|
||||
test.each(["system", "opencode"])(
|
||||
"prefers a new renderer mode over cached %s colors after failed probes",
|
||||
async (name) => {
|
||||
const colors = terminalColors()
|
||||
const expected = resolveThemeDocument(
|
||||
parseTheme(name === "system" ? generateSystem(colors, "light") : DEFAULT_THEMES.opencode),
|
||||
"light",
|
||||
),
|
||||
"light",
|
||||
)
|
||||
)
|
||||
const input = { colors, fail: false, themeMode: "dark" as "dark" | "light" }
|
||||
const term = renderer(input)
|
||||
const initial = await resolveRunTheme(term, { name })
|
||||
initial.block.syntax?.destroy()
|
||||
input.themeMode = "light"
|
||||
input.colors = emptyColors
|
||||
for (const fail of [true, false]) {
|
||||
input.fail = fail
|
||||
const theme = await resolveRunTheme(term, { name })
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
|
||||
expect(spread(theme.backgroundElement)).toBeLessThan(60)
|
||||
test("retains a usable system palette after background-only and empty probes", async () => {
|
||||
const input = { colors: terminalColors() }
|
||||
const expected = resolveThemeDocument(parseTheme(generateSystem(input.colors, "dark")), "dark")
|
||||
const term = renderer(input)
|
||||
const initial = await resolveRunTheme(term, { name: "system" })
|
||||
initial.block.syntax?.destroy()
|
||||
for (const colors of [{ ...emptyColors, defaultBackground: "#202020" }, emptyColors]) {
|
||||
input.colors = colors
|
||||
const theme = await resolveRunTheme(term, { name: "system" })
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
expect(rgba(theme.footer.text).intent).toBe("default")
|
||||
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
|
||||
expect(rgba(theme.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["system", "opencode"])(
|
||||
"retains and refreshes the %s scrollback palette across probe failures",
|
||||
async (name) => {
|
||||
const input = { fail: false, colors: terminalColors() }
|
||||
const term = renderer(input)
|
||||
const initial = await resolveRunTheme(term, { name })
|
||||
input.fail = true
|
||||
const retained = await resolveRunTheme(term, { name, mode: "light" })
|
||||
input.fail = false
|
||||
input.colors = terminalColors({ palette: Array.from({ length: 256 }, () => "#ff00ff") })
|
||||
const refreshed = await resolveRunTheme(term, { name })
|
||||
try {
|
||||
expect(rgba(retained.footer.surface).toInts()).toEqual(rgba(initial.footer.surface).toInts())
|
||||
expect(rgba(retained.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
|
||||
expect(rgba(refreshed.entry.reasoning.body).toInts()).not.toEqual(rgba(initial.entry.reasoning.body).toInts())
|
||||
} finally {
|
||||
initial.block.syntax?.destroy()
|
||||
retained.block.syntax?.destroy()
|
||||
refreshed.block.syntax?.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import {
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachmentWith,
|
||||
resolvePastedAttachments,
|
||||
} from "../../src/component/prompt/local-attachment"
|
||||
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||
return {
|
||||
@@ -81,4 +89,90 @@ describe("prompt local attachments", () => {
|
||||
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("resolves a single image path before splitting spaces", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "one image.png")
|
||||
await Bun.write(file, new Uint8Array([1, 2, 3]))
|
||||
|
||||
for (const input of [file, `'${file}'`, pathToFileURL(file).href]) {
|
||||
expect(await resolvePastedAttachments(input, "linux")).toEqual([
|
||||
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves quoted paths and URI lists as ordered attachments", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const image = path.join(tmp.path, "one image.png")
|
||||
const pdf = path.join(tmp.path, "two file.pdf")
|
||||
await Promise.all([Bun.write(image, new Uint8Array([1, 2, 3])), Bun.write(pdf, new Uint8Array([4, 5, 6]))])
|
||||
|
||||
for (const input of [
|
||||
`'${image}' "${pdf}"`,
|
||||
`# dropped files\r\n${pathToFileURL(image).href}\r\n${pathToFileURL(pdf).href}`,
|
||||
]) {
|
||||
expect(await resolvePastedAttachments(input, "linux")).toEqual([
|
||||
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
|
||||
{ type: "file", uri: "data:application/pdf;base64,BAUG", filename: "two file.pdf" },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back to plain text for unsupported or incomplete drops", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const image = path.join(tmp.path, "image.png")
|
||||
const text = path.join(tmp.path, "notes.txt")
|
||||
await Promise.all([Bun.write(image, new Uint8Array([1])), Bun.write(text, "notes")])
|
||||
|
||||
for (const input of [
|
||||
"",
|
||||
"plain\r\ntext",
|
||||
"https://example.com/image.png",
|
||||
text,
|
||||
`${image} ${text}`,
|
||||
`${image} ${path.join(tmp.path, "missing.png")}`,
|
||||
]) {
|
||||
expect(await resolvePastedAttachments(input, "linux")).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves SVG files as text with the original content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "image.svg")
|
||||
const content = "<svg />\r\n"
|
||||
await Bun.write(file, content)
|
||||
|
||||
expect(await resolvePastedAttachments(file, "linux")).toEqual([{ type: "text", content, filename: "image.svg" }])
|
||||
})
|
||||
|
||||
test("shares the byte budget across binary and SVG attachments", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const image = path.join(tmp.path, "image.png")
|
||||
const svg = path.join(tmp.path, "image.svg")
|
||||
const content = "<svg>\u00e9</svg>"
|
||||
await Promise.all([
|
||||
Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES - Buffer.byteLength(content))),
|
||||
Bun.write(svg, content),
|
||||
])
|
||||
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toMatchObject([
|
||||
{ type: "file", filename: "image.png" },
|
||||
{ type: "text", content, filename: "image.svg" },
|
||||
])
|
||||
await Bun.write(svg, content + " ")
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toBeUndefined()
|
||||
|
||||
await Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES + 1))
|
||||
expect(await resolvePastedAttachments(image, "linux")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds the number of resolved paths", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "image.png")
|
||||
await Bun.write(file, new Uint8Array([1]))
|
||||
|
||||
expect(await resolvePastedAttachments(Array(32).fill(file).join(" "), "linux")).toHaveLength(32)
|
||||
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), "linux")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user