splash screen

This commit is contained in:
Simon Klee
2026-03-30 14:47:11 +02:00
parent ba82c11091
commit 9c761ff619
10 changed files with 780 additions and 20 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
}

View 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)
}

View File

@@ -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

View File

@@ -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
}

View 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()
}
})
})

View File

@@ -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

View File

@@ -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")
})
})

View 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)
})
})