mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
splash screen
This commit is contained in:
@@ -57,6 +57,11 @@ type Inline = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SessionInfo = {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function inline(info: Inline) {
|
||||
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
|
||||
@@ -414,22 +419,78 @@ export const RunCommand = cmd({
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
if (!current?.data) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: args.session,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? current.data.title,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: current.data.id,
|
||||
title: current.data.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
||||
|
||||
if (base && args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: base.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? base.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
}
|
||||
}
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({
|
||||
title: name,
|
||||
permission: rules,
|
||||
})
|
||||
return result.data?.id
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title ?? name,
|
||||
}
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
@@ -664,11 +725,12 @@ export const RunCommand = cmd({
|
||||
return args.agent
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
const sess = await session(sdk)
|
||||
if (!sess?.id) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
const sessionID = sess.id
|
||||
await share(sdk, sessionID)
|
||||
|
||||
if (!args.interactive) {
|
||||
@@ -705,6 +767,8 @@ export const RunCommand = cmd({
|
||||
await runInteractiveMode({
|
||||
sdk,
|
||||
sessionID,
|
||||
sessionTitle: sess.title,
|
||||
resume: Boolean(args.session) && !args.fork,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCliRenderer, type CliRenderer } from "@opentui/core"
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { TuiConfig } from "../../../config/tui"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { RunFooter } from "./footer"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { formatUnknownError, runPromptTurn } from "./stream"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type { FooterApi, FooterKeybinds, RunInput } from "./types"
|
||||
@@ -208,6 +209,32 @@ type QueueInput = {
|
||||
run: (prompt: string, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function queueSplash(
|
||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||
state: SplashState,
|
||||
phase: keyof SplashState,
|
||||
write: ScrollbackWriter | undefined,
|
||||
): boolean {
|
||||
if (state[phase]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!write) {
|
||||
return false
|
||||
}
|
||||
|
||||
state[phase] = true
|
||||
renderer.writeToScrollback(write)
|
||||
renderer.requestRender()
|
||||
return true
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const q: string[] = []
|
||||
@@ -349,6 +376,14 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
resolveFirstPrompt(input.sdk, input.sessionID),
|
||||
resolvePromptHistory(input.sdk, input.sessionID),
|
||||
])
|
||||
const meta = splashMeta({
|
||||
title: input.sessionTitle,
|
||||
session_id: input.sessionID,
|
||||
})
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const variants = info.variants
|
||||
let activeVariant = input.variant
|
||||
let aborting = false
|
||||
@@ -408,13 +443,6 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
aborting = false
|
||||
})
|
||||
},
|
||||
onExit: () => {
|
||||
try {
|
||||
shutdown(renderer)
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
const sigint = () => {
|
||||
footer.requestExit()
|
||||
@@ -422,7 +450,20 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
process.on("SIGINT", sigint)
|
||||
|
||||
try {
|
||||
footer.append("system", "Interactive mode enabled. Type /exit or /quit to finish.")
|
||||
if (!input.resume) {
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
entrySplash({
|
||||
...meta,
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
|
||||
let includeFiles = true
|
||||
await runPromptQueue({
|
||||
footer,
|
||||
@@ -454,6 +495,24 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
})
|
||||
} finally {
|
||||
process.off("SIGINT", sigint)
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
const hasMessages = !(await resolveFirstPrompt(input.sdk, input.sessionID))
|
||||
if (hasMessages) {
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...meta,
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
footer.close()
|
||||
footer.destroy()
|
||||
shutdown(renderer)
|
||||
|
||||
@@ -119,6 +119,38 @@ function build(kind: EntryKind, text: string, ctx: ScrollbackRenderContext, them
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlock(text: string): string {
|
||||
return text.replace(/\r/g, "")
|
||||
}
|
||||
|
||||
function buildBlock(text: string, ctx: ScrollbackRenderContext, theme: RunEntryTheme): ScrollbackSnapshot {
|
||||
const body = normalizeBlock(text)
|
||||
const width = Math.max(1, ctx.width)
|
||||
const content = body.endsWith("\n") ? body : `${body}\n`
|
||||
const root = new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-block-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height: 1,
|
||||
content,
|
||||
wrapMode: "word",
|
||||
fg: theme.system.body,
|
||||
})
|
||||
const height = Math.max(1, root.scrollHeight)
|
||||
root.height = height
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function entryWriter(
|
||||
kind: EntryKind,
|
||||
text: string,
|
||||
@@ -126,3 +158,7 @@ export function entryWriter(
|
||||
): ScrollbackWriter {
|
||||
return (ctx) => build(kind, text, ctx, theme)
|
||||
}
|
||||
|
||||
export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
|
||||
return (ctx) => buildBlock(text, ctx, theme)
|
||||
}
|
||||
|
||||
248
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
248
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { logo, logoCells } from "../../logo"
|
||||
import type { RunEntryTheme } from "./theme"
|
||||
|
||||
export const SPLASH_TITLE_LIMIT = 50
|
||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunEntryTheme
|
||||
background: ColorInput
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
title: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(text.trim(), SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
root: BoxRenderable,
|
||||
ctx: ScrollbackRenderContext,
|
||||
line: {
|
||||
left: number
|
||||
top: number
|
||||
text: string
|
||||
fg: ColorInput
|
||||
bg?: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
): void {
|
||||
if (line.left >= ctx.width) {
|
||||
return
|
||||
}
|
||||
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-line-${id++}`,
|
||||
position: "absolute",
|
||||
left: line.left,
|
||||
top: line.top,
|
||||
width: Math.max(1, ctx.width - line.left),
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
content: line.text,
|
||||
fg: line.fg,
|
||||
bg: line.bg,
|
||||
attributes: line.attrs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function push(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
left: number,
|
||||
top: number,
|
||||
text: string,
|
||||
fg: ColorInput,
|
||||
bg?: ColorInput,
|
||||
attrs?: number,
|
||||
): void {
|
||||
lines.push({ left, top, text, fg, bg, attrs })
|
||||
}
|
||||
|
||||
function color(input: ColorInput, fallback: RGBA): RGBA {
|
||||
if (input instanceof RGBA) {
|
||||
return input
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (input === "transparent" || input === "none") {
|
||||
return RGBA.fromValues(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (input.startsWith("#")) {
|
||||
return RGBA.fromHex(input)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function shade(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
const r = base.r + (overlay.r - base.r) * alpha
|
||||
const g = base.g + (overlay.g - base.g) * alpha
|
||||
const b = base.b + (overlay.b - base.b) * alpha
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function draw(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
row: string,
|
||||
input: {
|
||||
left: number
|
||||
top: number
|
||||
fg: ColorInput
|
||||
shadow: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
) {
|
||||
let x = input.left
|
||||
for (const cell of logoCells(row)) {
|
||||
if (cell.mark === "full") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "mix") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "top") {
|
||||
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
|
||||
x += 1
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", 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 bg = color(input.background, RGBA.fromValues(0, 0, 0, 0))
|
||||
const left = color(input.theme.system.body, RGBA.fromInts(100, 116, 139))
|
||||
const right = color(input.theme.assistant.body, RGBA.fromInts(248, 250, 252))
|
||||
const leftShadow = shade(bg, left, 0.25)
|
||||
const rightShadow = shade(bg, right, 0.25)
|
||||
let y = 0
|
||||
|
||||
for (let i = 0; i < logo.left.length; i += 1) {
|
||||
const leftText = logo.left[i] ?? ""
|
||||
const rightText = logo.right[i] ?? ""
|
||||
|
||||
draw(lines, leftText, {
|
||||
left: 2,
|
||||
top: y,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
draw(lines, rightText, {
|
||||
left: 2 + leftText.length + 1,
|
||||
top: y,
|
||||
fg: right,
|
||||
shadow: rightShadow,
|
||||
attrs: TextAttributes.BOLD,
|
||||
})
|
||||
y += 1
|
||||
}
|
||||
|
||||
y += 1
|
||||
|
||||
const label = "Session".padEnd(10, " ")
|
||||
push(lines, 2, y, label, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(lines, 2 + label.length, y, meta.title, input.theme.assistant.body, undefined, TextAttributes.BOLD)
|
||||
y += 1
|
||||
|
||||
if (kind === "entry") {
|
||||
push(lines, 2, y, "Type /exit or /quit to finish.", input.theme.system.body, undefined, undefined)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const next = "Continue".padEnd(10, " ")
|
||||
push(lines, 2, y, next, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
2 + next.length,
|
||||
y,
|
||||
`opencode -s ${meta.session_id}`,
|
||||
input.theme.assistant.body,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
y += 1
|
||||
}
|
||||
|
||||
const height = Math.max(1, y + 1)
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-${kind}-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
|
||||
@@ -4,3 +4,44 @@ export const logo = {
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
|
||||
export type LogoCell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
export function logoCells(line: string): LogoCell[] {
|
||||
const cells: LogoCell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
cells.push({
|
||||
char: " ",
|
||||
mark: "full",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "mix",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "top",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cells.push({
|
||||
char,
|
||||
mark: "text",
|
||||
})
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { RunFooter } from "../../../src/cli/cmd/run/footer"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function create() {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
setup.renderer.screenMode = "split-footer"
|
||||
setup.renderer.footerHeight = 6
|
||||
|
||||
let interrupts = 0
|
||||
let exits = 0
|
||||
|
||||
const footer = new RunFooter(setup.renderer as any, {
|
||||
agentLabel: "Agent default",
|
||||
modelLabel: "Model default",
|
||||
first: false,
|
||||
theme: RUN_THEME_FALLBACK,
|
||||
keybinds: {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
},
|
||||
onInterrupt: () => {
|
||||
interrupts += 1
|
||||
},
|
||||
onExit: () => {
|
||||
exits += 1
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
setup,
|
||||
footer,
|
||||
interrupts: () => interrupts,
|
||||
exits: () => exits,
|
||||
destroy() {
|
||||
footer.destroy()
|
||||
setup.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("run footer", () => {
|
||||
test("interrupt requires running phase", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(false)
|
||||
expect(ctx.interrupts()).toBe(0)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("double interrupt triggers callback once", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
ctx.footer.patch({ phase: "running" })
|
||||
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(true)
|
||||
expect((ctx.footer as any).state().interrupt).toBe(1)
|
||||
expect((ctx.footer as any).state().status).toBe("esc again to interrupt")
|
||||
expect(ctx.interrupts()).toBe(0)
|
||||
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(true)
|
||||
expect((ctx.footer as any).state().interrupt).toBe(0)
|
||||
expect((ctx.footer as any).state().status).toBe("interrupting")
|
||||
expect(ctx.interrupts()).toBe(1)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("double exit closes and calls onExit once", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.footer.isClosed).toBe(false)
|
||||
expect((ctx.footer as any).state().exit).toBe(1)
|
||||
expect((ctx.footer as any).state().status).toBe("Press Ctrl-c again to exit")
|
||||
expect(ctx.exits()).toBe(0)
|
||||
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.footer.isClosed).toBe(true)
|
||||
expect((ctx.footer as any).state().exit).toBe(0)
|
||||
expect((ctx.footer as any).state().status).toBe("exiting")
|
||||
expect(ctx.exits()).toBe(1)
|
||||
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.exits()).toBe(1)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("row sync clamps footer resize range", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
const sync = (ctx.footer as any).syncRows as (rows: number) => void
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(6)
|
||||
sync(99)
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(11)
|
||||
sync(-3)
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(6)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { runPromptQueue } from "../../../src/cli/cmd/run/runtime"
|
||||
import { queueSplash, runPromptQueue } from "../../../src/cli/cmd/run/runtime"
|
||||
import type { EntryKind, FooterApi, FooterPatch } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
function createFooter() {
|
||||
@@ -75,6 +75,34 @@ function createFooter() {
|
||||
}
|
||||
|
||||
describe("run runtime", () => {
|
||||
test("queues entry and exit splash only once", () => {
|
||||
const writes: unknown[] = []
|
||||
let renders = 0
|
||||
const renderer = {
|
||||
writeToScrollback(write: unknown) {
|
||||
writes.push(write)
|
||||
},
|
||||
requestRender() {
|
||||
renders += 1
|
||||
},
|
||||
} as any
|
||||
|
||||
const state = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
|
||||
const write = () => ({}) as any
|
||||
|
||||
expect(queueSplash(renderer, state, "entry", write)).toBe(true)
|
||||
expect(queueSplash(renderer, state, "entry", write)).toBe(false)
|
||||
expect(queueSplash(renderer, state, "exit", write)).toBe(true)
|
||||
expect(queueSplash(renderer, state, "exit", write)).toBe(false)
|
||||
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(renders).toBe(2)
|
||||
})
|
||||
|
||||
test("returns immediately when footer is already closed", async () => {
|
||||
const ui = createFooter()
|
||||
let calls = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { entryWriter, normalizeEntry } from "../../../src/cli/cmd/run/scrollback"
|
||||
import { blockWriter, entryWriter, normalizeEntry } from "../../../src/cli/cmd/run/scrollback"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
import type { EntryKind } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
@@ -34,6 +34,34 @@ async function draw(kind: EntryKind, text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function drawBlock(text: string) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 80,
|
||||
height: 12,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = blockWriter(
|
||||
text,
|
||||
RUN_THEME_FALLBACK.entry,
|
||||
)({
|
||||
width: 80,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
return {
|
||||
snap,
|
||||
root,
|
||||
text: root.plainText as string,
|
||||
fg: root.fg,
|
||||
attrs: root.attributes ?? 0,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a && typeof a === "object" && "equals" in a && typeof (a as any).equals === "function") {
|
||||
return (a as any).equals(b)
|
||||
@@ -110,4 +138,21 @@ describe("run scrollback", () => {
|
||||
expect(same(error.fg, RUN_THEME_FALLBACK.entry.error.body)).toBe(true)
|
||||
expect(Boolean(error.attrs & TextAttributes.BOLD)).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves multiline blocks with intentional spacing", async () => {
|
||||
const text = "+-------+\n| splash |\n+-------+\n\nSession Demo"
|
||||
const out = await drawBlock(text)
|
||||
|
||||
expect(out.text).toBe(`${text}\n`)
|
||||
expect(out.snap.width).toBe(80)
|
||||
expect(out.snap.rowColumns).toBe(80)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps interior whitespace in preformatted blocks", async () => {
|
||||
const out = await drawBlock("Session title\nContinue opencode -s abc")
|
||||
expect(out.text).toContain("Session title")
|
||||
expect(out.text).toContain("Continue opencode -s abc")
|
||||
})
|
||||
})
|
||||
|
||||
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import {
|
||||
SPLASH_TITLE_FALLBACK,
|
||||
SPLASH_TITLE_LIMIT,
|
||||
entrySplash,
|
||||
exitSplash,
|
||||
splashMeta,
|
||||
} from "../../../src/cli/cmd/run/splash"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function draw(write: ReturnType<typeof entrySplash>) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 24,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = write({
|
||||
width: 100,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
const children = root.getChildren() as any[]
|
||||
const rows = new Map<number, string[]>()
|
||||
|
||||
for (const child of children) {
|
||||
if (typeof child.left !== "number" || typeof child.top !== "number") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof child.plainText !== "string" || child.plainText.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = rows.get(child.top) ?? []
|
||||
for (let i = 0; i < child.plainText.length; i += 1) {
|
||||
row[child.left + i] = child.plainText[i]
|
||||
}
|
||||
rows.set(child.top, row)
|
||||
}
|
||||
|
||||
const lines = [...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row.join("").replace(/\s+$/g, ""))
|
||||
|
||||
return {
|
||||
snap,
|
||||
lines,
|
||||
children,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
describe("run splash", () => {
|
||||
test("builds entry text with logo", () => {
|
||||
const text = entrySplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.some((line) => line.includes("█▀▀█"))).toBe(true)
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Type /exit or /quit to finish.")
|
||||
expect(out.children.some((item) => item.plainText === " " && item.bg && item.bg.a > 0)).toBe(true)
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("builds exit text with aligned rows", () => {
|
||||
const text = exitSplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Continue opencode -s sess-1")
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
test("applies stable fallback title", () => {
|
||||
expect(
|
||||
splashMeta({
|
||||
title: undefined,
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
|
||||
expect(
|
||||
splashMeta({
|
||||
title: " ",
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
})
|
||||
|
||||
test("truncates title with tui cap", () => {
|
||||
const meta = splashMeta({
|
||||
title: "x".repeat(80),
|
||||
session_id: "sess-1",
|
||||
})
|
||||
|
||||
expect(meta.title.length).toBeLessThanOrEqual(SPLASH_TITLE_LIMIT)
|
||||
expect(meta.title.endsWith("…")).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user