Skip to content

Commit eff0387

Browse files
committed
splash screen pass
1 parent b3ecd4d commit eff0387

5 files changed

Lines changed: 108 additions & 63 deletions

File tree

packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// Also wires SIGINT so Ctrl-c during a turn triggers the two-press exit
1010
// sequence through RunFooter.requestExit().
1111
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
12+
import { Session as SessionApi } from "@/session/session"
1213
import * as Locale from "@/util/locale"
1314
import { withRunSpan } from "./otel"
1415
import { entrySplash, exitSplash, splashMeta } from "./splash"
@@ -28,7 +29,6 @@ import type {
2829
import { formatModelLabel } from "./variant.shared"
2930

3031
const FOOTER_HEIGHT = 7
31-
const DEFAULT_TITLE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
3232

3333
type SplashState = {
3434
entry: boolean
@@ -70,7 +70,7 @@ export type LifecycleInput = {
7070

7171
export type Lifecycle = {
7272
footer: FooterApi
73-
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string }): Promise<void>
73+
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
7474
}
7575

7676
// Gracefully tears down the renderer. Order matters: switch external output
@@ -95,7 +95,7 @@ function shutdown(renderer: CliRenderer): void {
9595
}
9696

9797
function splashInfo(title: string | undefined, history: RunPrompt[]) {
98-
if (title && !DEFAULT_TITLE.test(title)) {
98+
if (title && !SessionApi.isDefaultTitle(title)) {
9999
return {
100100
title,
101101
showSession: true,
@@ -235,7 +235,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
235235
process.on("SIGINT", sigint)
236236

237237
let closed = false
238-
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => {
238+
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }) => {
239239
if (closed) {
240240
return
241241
}
@@ -256,7 +256,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
256256
const show = renderer.isDestroyed ? false : next.showExit
257257
if (!renderer.isDestroyed && show) {
258258
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
259-
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, input.history)
259+
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
260260
queueSplash(
261261
renderer,
262262
state,

packages/opencode/src/cli/cmd/run/runtime.queue.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type QueueInput = {
2727
footer: FooterApi
2828
initialInput?: string
2929
trace?: Trace
30-
onPrompt?: () => void
30+
onSend?: (prompt: RunPrompt) => void
3131
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
3232
}
3333

@@ -126,6 +126,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
126126
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
127127
input.trace?.write("ui.commit", commit)
128128
input.footer.append(commit)
129+
input.onSend?.(prompt)
129130

130131
const next = await Promise.race([task, stop.promise])
131132
if (next.type === "closed") {
@@ -185,7 +186,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
185186
return
186187
}
187188

188-
input.onPrompt?.()
189189
state.queue.push(prompt)
190190
emit(
191191
{

packages/opencode/src/cli/cmd/run/runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { createRuntimeLifecycle } from "./runtime.lifecycle"
2020
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
2121
import { trace } from "./trace"
2222
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
23-
import type { RunInput } from "./types"
23+
import type { RunInput, RunPrompt } from "./types"
2424

2525
/** @internal Exported for testing */
2626
export { pickVariant, resolveVariant } from "./variant.shared"
@@ -80,6 +80,7 @@ type RuntimeState = {
8080
limits: Record<string, number>
8181
activeVariant: string | undefined
8282
sessionID: string
83+
history: RunPrompt[]
8384
sessionTitle?: string
8485
agent: string | undefined
8586
demo?: ReturnType<typeof createRunDemo>
@@ -156,6 +157,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
156157
limits: {},
157158
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
158159
sessionID: ctx.sessionID,
160+
history: [...session.history],
159161
sessionTitle: ctx.sessionTitle,
160162
agent: ctx.agent,
161163
}
@@ -179,7 +181,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
179181

180182
state.session = input.resolveSession(ctx).then((next) => {
181183
state.sessionID = next.sessionID
182-
state.sessionTitle = next.sessionTitle
184+
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
183185
state.agent = next.agent
184186
setRunSpanAttributes(span, {
185187
"opencode.agent.name": state.agent,
@@ -405,8 +407,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
405407
footer,
406408
initialInput: input.initialInput,
407409
trace: log,
408-
onPrompt: () => {
410+
onSend: (prompt) => {
409411
state.shown = true
412+
state.history.push(prompt)
410413
},
411414
run: async (prompt, signal) => {
412415
if (state.demo && (await state.demo.prompt(prompt, signal))) {
@@ -489,6 +492,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
489492
showExit: state.shown && hasSession(input, state),
490493
sessionTitle: title,
491494
sessionID: state.sessionID,
495+
history: state.history,
492496
})
493497
}
494498
},

packages/opencode/src/cli/cmd/run/splash.ts

Lines changed: 76 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
// Entry and exit splash banners for direct interactive mode scrollback.
22
//
3-
// Renders the opencode ASCII logo with half-block shadow characters, the
4-
// session title, and contextual hints (entry: "/exit to finish", exit:
5-
// "opencode -s <id>" to resume). These are scrollback snapshots, so they
6-
// become immutable terminal history once committed.
3+
// Renders the full opencode entry logo and a compact [O] exit badge, plus
4+
// session metadata and the resume command. These are scrollback snapshots, so
5+
// they become immutable terminal history once committed.
76
//
8-
// The logo uses a cell-based renderer. cells() classifies each character
9-
// in the logo template as text, full-block, half-block-mix, or
7+
// Both variants use a cell-based renderer. cells() classifies each character
8+
// in the source template as text, full-block, half-block-mix, or
109
// half-block-top, and draw() renders it with foreground/background shadow
1110
// colors from the theme.
1211
import {
@@ -20,7 +19,7 @@ import {
2019
type ScrollbackWriter,
2120
} from "@opentui/core"
2221
import * as Locale from "@/util/locale"
23-
import { logo } from "@/cli/logo"
22+
import { go, logo } from "@/cli/logo"
2423
import type { RunSplashTheme } from "./theme"
2524

2625
export const SPLASH_TITLE_LIMIT = 50
@@ -77,11 +76,27 @@ function title(text: string | undefined): string {
7776
return SPLASH_TITLE_FALLBACK
7877
}
7978

80-
if (!text.trim()) {
79+
let value = ""
80+
let gap = false
81+
for (const char of text.trim()) {
82+
if (char === " " || char === "\n" || char === "\r" || char === "\t") {
83+
gap = true
84+
continue
85+
}
86+
87+
if (gap && value.length > 0) {
88+
value += " "
89+
}
90+
91+
value += char
92+
gap = false
93+
}
94+
95+
if (!value) {
8196
return SPLASH_TITLE_FALLBACK
8297
}
8398

84-
return Locale.truncate(text.trim(), SPLASH_TITLE_LIMIT)
99+
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
85100
}
86101

87102
function write(
@@ -188,58 +203,66 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
188203
const left = color(input.theme.left, fallback(81, "#38bdf8"))
189204
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
190205
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
191-
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
192-
let y = 0
193-
194-
for (let i = 0; i < logo.left.length; i += 1) {
195-
const leftText = logo.left[i] ?? ""
196-
const rightText = logo.right[i] ?? ""
197-
198-
draw(lines, leftText, {
199-
left: 0,
200-
top: y,
201-
fg: left,
202-
shadow: leftShadow,
203-
})
204-
draw(lines, rightText, {
205-
left: leftText.length + 1,
206-
top: y,
207-
fg: right,
208-
shadow: rightShadow,
209-
})
210-
y += 1
211-
}
206+
let height = 1
212207

213-
y += 1
208+
if (kind === "entry") {
209+
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
210+
211+
for (let i = 0; i < logo.left.length; i += 1) {
212+
const leftText = logo.left[i] ?? ""
213+
const rightText = logo.right[i] ?? ""
214+
215+
draw(lines, leftText, {
216+
left: 0,
217+
top: i,
218+
fg: left,
219+
shadow: leftShadow,
220+
})
221+
draw(lines, rightText, {
222+
left: leftText.length + 1,
223+
top: i,
224+
fg: right,
225+
shadow: rightShadow,
226+
})
227+
}
214228

215-
if (input.showSession !== false) {
216-
const label = "Session".padEnd(10, " ")
217-
push(lines, 0, y, label, input.theme.left, undefined, TextAttributes.DIM)
218-
push(lines, label.length, y, meta.title, input.theme.right, undefined, TextAttributes.BOLD)
219-
y += 1
220-
}
229+
height = logo.left.length + 1
221230

222-
if (kind === "entry") {
223-
push(lines, 0, y, "Type /exit to finish.", input.theme.left, undefined, undefined)
224-
y += 1
231+
if (input.showSession !== false) {
232+
const top = logo.left.length + 1
233+
const label = "Session".padEnd(10, " ")
234+
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
235+
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
236+
height = top + 1
237+
}
225238
}
226239

227240
if (kind === "exit") {
228-
const next = "Continue".padEnd(10, " ")
229-
push(lines, 0, y, next, input.theme.left, undefined, TextAttributes.DIM)
230-
push(
231-
lines,
232-
next.length,
233-
y,
234-
`opencode -s ${meta.session_id}`,
235-
input.theme.right,
236-
undefined,
237-
TextAttributes.BOLD,
238-
)
239-
y += 1
241+
const mark = go.right.slice(1)
242+
const top = 1
243+
const body_left = (mark[0]?.length ?? 0) + 2
244+
const session = "Session "
245+
const label = "Continue "
246+
247+
for (let i = 0; i < mark.length; i += 1) {
248+
draw(lines, mark[i] ?? "", {
249+
left: 0,
250+
top: top + i,
251+
fg: left,
252+
shadow: leftShadow,
253+
})
254+
}
255+
256+
if (input.showSession !== false) {
257+
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
258+
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
259+
}
260+
261+
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
262+
push(lines, body_left + label.length, top + 1, `opencode run -i -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD)
263+
height = top + mark.length
240264
}
241265

242-
const height = Math.max(1, y)
243266
const root = new BoxRenderable(ctx.renderContext, {
244267
id: `run-direct-splash-${kind}-${id++}`,
245268
position: "absolute",

packages/opencode/test/cli/run/runtime.queue.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,24 @@ describe("run runtime queue", () => {
129129
])
130130
})
131131

132+
test("passes prompts to onSend", async () => {
133+
const ui = footer()
134+
const seen: string[] = []
135+
136+
await runPromptQueue({
137+
footer: ui.api,
138+
initialInput: " hello ",
139+
onSend: (input) => {
140+
seen.push(input.text)
141+
},
142+
run: async () => {
143+
ui.api.close()
144+
},
145+
})
146+
147+
expect(seen).toEqual([" hello "])
148+
})
149+
132150
test("runs queued prompts in order", async () => {
133151
const ui = footer()
134152
const seen: string[] = []

0 commit comments

Comments
 (0)