Compare commits

...

22 Commits
dev ... oc-run

Author SHA1 Message Date
Simon Klee
eb85552738 add chunked direct scrollback rendering 2026-04-02 08:49:25 +02:00
Simon Klee
dbf6690e9b startup time improvements 2026-04-02 08:14:46 +02:00
Simon Klee
48e30e7f26 color intent 2026-04-01 16:07:24 +02:00
Simon Klee
4e45169eec variance peristance 2026-04-01 16:07:24 +02:00
Simon Klee
1e00672517 move duration to left 2026-04-01 16:07:24 +02:00
Simon Klee
9c761ff619 splash screen 2026-04-01 16:07:24 +02:00
Simon Klee
ba82c11091 test reducer 2026-04-01 16:07:24 +02:00
Simon Klee
d179a5eeb3 simplify output 2026-04-01 16:07:24 +02:00
Simon Klee
3d6324459e add a session data reducer 2026-04-01 16:07:24 +02:00
Simon Klee
d92cf629f6 more exit handling 2026-04-01 16:07:24 +02:00
Simon Klee
857c0aa258 cleanup theme 2026-04-01 16:07:24 +02:00
Simon Klee
93acb5411f history and exit 2026-04-01 16:07:24 +02:00
Simon Klee
809e46c988 fix history 2026-04-01 16:07:24 +02:00
Simon Klee
56c9f68368 footer cleanup 2026-04-01 16:07:24 +02:00
Simon Klee
4dad8d4bcb life-cycle things 2026-04-01 16:07:23 +02:00
Simon Klee
02a958b30c fix ctrl-c/fix flickering 2026-04-01 16:07:23 +02:00
Simon Klee
7871920b56 use solid 2026-04-01 16:07:23 +02:00
Simon Klee
82075fa920 improve footer api 2026-04-01 16:07:23 +02:00
Simon Klee
a3d3bf9a71 cleanup 2026-04-01 16:07:23 +02:00
Simon Klee
3146c216ec wip 2026-04-01 16:07:23 +02:00
Simon Klee
df84677212 wip 2026-04-01 16:07:23 +02:00
Simon Klee
685e237c4c wip 2026-04-01 16:07:22 +02:00
20 changed files with 6605 additions and 94 deletions

View File

@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { runInteractiveLocalMode, runInteractiveMode } from "./run/runtime"
type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
@@ -34,6 +35,13 @@ type ToolProps<T extends Tool.Info> = {
part: ToolPart
}
type FilePart = {
type: "file"
url: string
filename: string
mime: string
}
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
@@ -49,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)
@@ -302,12 +315,40 @@ export const RunCommand = cmd({
describe: "show thinking blocks",
default: false,
})
.option("interactive", {
alias: ["i"],
type: "boolean",
describe: "run in direct interactive split-footer mode",
default: false,
})
},
handler: async (args) => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
if (args.interactive && args.command) {
UI.error("--interactive cannot be used with --command")
process.exit(1)
}
if (args.interactive && args.format === "json") {
UI.error("--interactive cannot be used with --format json")
process.exit(1)
}
if (args.interactive && !process.stdin.isTTY) {
UI.error("--interactive requires a TTY")
process.exit(1)
}
if (args.interactive && !process.stdout.isTTY) {
UI.error("--interactive requires a TTY stdout")
process.exit(1)
}
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
@@ -320,7 +361,7 @@ export const RunCommand = cmd({
}
})()
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
const files: FilePart[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@@ -344,7 +385,7 @@ export const RunCommand = cmd({
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0 && !args.command) {
if (message.trim().length === 0 && !args.command && !args.interactive) {
UI.error("You must provide a message or a command")
process.exit(1)
}
@@ -378,19 +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 result = await sdk.session.create({
title: name,
permission: rules,
})
const id = result.data?.id
if (!id) {
return
}
return {
id,
title: result.data?.title ?? name,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
@@ -408,6 +508,77 @@ export const RunCommand = cmd({
}
}
async function localAgent() {
if (!args.agent) return undefined
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
)
return undefined
}
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
}
async function attachAgent(sdk: OpencodeClient) {
if (!args.agent) return undefined
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const agent = modes.find((a) => a.name === args.agent)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
}
async function pickAgent(sdk: OpencodeClient) {
if (!args.agent) return undefined
if (args.attach) {
return attachAgent(sdk)
}
return localAgent()
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
@@ -432,21 +603,27 @@ export const RunCommand = cmd({
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
process.stdout.write(
JSON.stringify({
type,
timestamp: Date.now(),
sessionID,
...data,
}) + EOL,
)
return true
}
return false
}
const events = await sdk.event.subscribe()
let error: string | undefined
async function loop() {
async function loop(events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
let error: string | undefined
for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionID &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
@@ -558,89 +735,35 @@ export const RunCommand = cmd({
}
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
const agent = await pickAgent(sdk)
// When attaching, validate against the running server instead of local Instance state.
if (args.attach) {
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const agent = modes.find((a) => a.name === args.agent)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
}
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
)
return undefined
}
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
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)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
if (!args.interactive) {
const events = await sdk.event.subscribe()
loop(events).catch((e) => {
console.error(e)
process.exit(1)
})
} else {
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
return
}
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
@@ -649,7 +772,44 @@ export const RunCommand = cmd({
variant: args.variant,
parts: [...files, { type: "text", text: message }],
})
return
}
const model = args.model ? Provider.parseModel(args.model) : undefined
await runInteractiveMode({
sdk,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session) && !args.fork,
agent,
model,
variant: args.variant,
files,
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
thinking: args.thinking,
})
return
}
if (args.interactive && !args.attach && !args.session && !args.continue) {
const model = args.model ? Provider.parseModel(args.model) : undefined
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
return await runInteractiveLocalMode({
fetch: fetchFn,
resolveAgent: localAgent,
session,
share,
agent: args.agent,
model,
variant: args.variant,
files,
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
thinking: args.thinking,
})
}
if (args.attach) {
@@ -660,7 +820,11 @@ export const RunCommand = cmd({
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
const sdk = createOpencodeClient({
baseUrl: args.attach,
directory,
headers,
})
return await execute(sdk)
}
@@ -669,7 +833,10 @@ export const RunCommand = cmd({
const request = new Request(input, init)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
})
await execute(sdk)
})
},

View File

@@ -0,0 +1,387 @@
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { Keybind } from "../../../util/keybind"
import { RunFooterView, TEXTAREA_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.view"
import { entryWriter, normalizeEntry } from "./scrollback"
import type { RunTheme } from "./theme"
import type { FooterApi, FooterKeybinds, FooterPatch, FooterState, StreamCommit } from "./types"
type CycleResult = {
modelLabel?: string
status?: string
}
type RunFooterOptions = {
agentLabel: string
modelLabel: string
first: boolean
history?: string[]
theme: RunTheme
keybinds: FooterKeybinds
onCycleVariant?: () => CycleResult | void
onInterrupt?: () => void
onExit?: () => void
}
export class RunFooter implements FooterApi {
private closed = false
private destroyed = false
private prompts = new Set<(text: string) => void>()
private closes = new Set<() => void>()
private base: number
private rows = TEXTAREA_MIN_ROWS
private state: Accessor<FooterState>
private setState: Setter<FooterState>
private settle = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private interruptHint: string
constructor(
private renderer: CliRenderer,
private options: RunFooterOptions,
) {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: options.modelLabel,
duration: "",
usage: "",
first: options.first,
interrupt: 0,
exit: 0,
})
this.state = state
this.setState = setState
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.interruptHint = this.printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
void render(
() =>
createComponent(RunFooterView, {
state: this.state,
theme: options.theme.footer,
keybinds: options.keybinds,
history: options.history,
agent: options.agentLabel,
onSubmit: this.handlePrompt,
onCycle: this.handleCycle,
onInterrupt: this.handleInterrupt,
onExitRequest: this.handleExit,
onExit: () => this.close(),
onRows: this.syncRows,
onStatus: this.setStatus,
}),
this.renderer as unknown as Parameters<typeof render>[1],
).catch(() => {
if (!this.destroyed && !this.renderer.isDestroyed) {
this.close()
}
})
}
public get isClosed(): boolean {
return this.closed || this.destroyed || this.renderer.isDestroyed
}
public onPrompt(fn: (text: string) => void): () => void {
this.prompts.add(fn)
return () => {
this.prompts.delete(fn)
}
}
public onClose(fn: () => void): () => void {
if (this.isClosed) {
fn()
return () => {}
}
this.closes.add(fn)
return () => {
this.closes.delete(fn)
}
}
public patch(next: FooterPatch): void {
if (this.destroyed || this.renderer.isDestroyed) {
return
}
const prev = this.state()
const state = {
phase: next.phase ?? prev.phase,
status: typeof next.status === "string" ? next.status : prev.status,
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
model: typeof next.model === "string" ? next.model : prev.model,
duration: typeof next.duration === "string" ? next.duration : prev.duration,
usage: typeof next.usage === "string" ? next.usage : prev.usage,
first: typeof next.first === "boolean" ? next.first : prev.first,
interrupt:
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
? Math.max(0, Math.floor(next.interrupt))
: prev.interrupt,
exit:
typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
}
if (state.phase === "idle") {
state.interrupt = 0
}
this.setState(state)
}
public append(commit: StreamCommit): void {
if (this.destroyed || this.renderer.isDestroyed) {
return
}
if (!normalizeEntry(commit)) {
return
}
this.renderer.writeToScrollback(entryWriter(commit, this.options.theme.entry))
this.scheduleSettleRender()
}
public close(): void {
if (this.closed) {
return
}
this.notifyClose()
}
public requestExit(): boolean {
return this.handleExit()
}
public destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true
this.notifyClose()
this.clearInterruptTimer()
this.clearExitTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.prompts.clear()
this.closes.clear()
}
private notifyClose(): void {
if (this.closed) {
return
}
this.closed = true
for (const fn of [...this.closes]) {
fn()
}
}
private setStatus = (status: string): void => {
this.patch({ status })
}
private syncRows = (value: number): void => {
if (this.destroyed || this.renderer.isDestroyed) {
return
}
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, value))
if (rows === this.rows) {
return
}
this.rows = rows
const min = this.base + TEXTAREA_MIN_ROWS
const max = this.base + TEXTAREA_MAX_ROWS
const height = Math.max(min, Math.min(max, this.base + rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
}
}
private handlePrompt = (text: string): boolean => {
if (this.isClosed) {
return false
}
if (this.state().first) {
this.patch({ first: false })
}
if (this.prompts.size === 0) {
this.patch({ status: "input queue unavailable" })
return false
}
for (const fn of [...this.prompts]) {
fn(text)
}
return true
}
private handleCycle = (): void => {
const result = this.options.onCycleVariant?.()
if (!result) {
this.patch({ status: "no variants available" })
return
}
const patch: FooterPatch = {
status: result.status ?? "variant updated",
}
if (result.modelLabel) {
patch.model = result.modelLabel
}
this.patch(patch)
}
private clearInterruptTimer(): void {
if (!this.interruptTimeout) {
return
}
clearTimeout(this.interruptTimeout)
this.interruptTimeout = undefined
}
private armInterruptTimer(): void {
this.clearInterruptTimer()
this.interruptTimeout = setTimeout(() => {
this.interruptTimeout = undefined
if (this.destroyed || this.renderer.isDestroyed || this.state().phase !== "running") {
return
}
this.patch({ interrupt: 0 })
}, 5000)
}
private clearExitTimer(): void {
if (!this.exitTimeout) {
return
}
clearTimeout(this.exitTimeout)
this.exitTimeout = undefined
}
private armExitTimer(): void {
this.clearExitTimer()
this.exitTimeout = setTimeout(() => {
this.exitTimeout = undefined
if (this.destroyed || this.renderer.isDestroyed || this.isClosed) {
return
}
this.patch({ exit: 0 })
}, 5000)
}
private handleInterrupt = (): boolean => {
if (this.isClosed || this.state().phase !== "running") {
return false
}
const next = this.state().interrupt + 1
this.patch({ interrupt: next })
if (next < 2) {
this.armInterruptTimer()
this.patch({ status: `${this.interruptHint} again to interrupt` })
return true
}
this.clearInterruptTimer()
this.patch({ interrupt: 0, status: "interrupting" })
this.options.onInterrupt?.()
return true
}
private handleExit = (): boolean => {
if (this.isClosed) {
return true
}
this.clearInterruptTimer()
const next = this.state().exit + 1
this.patch({ exit: next, interrupt: 0 })
if (next < 2) {
this.armExitTimer()
this.patch({ status: "Press Ctrl-c again to exit" })
return true
}
this.clearExitTimer()
this.patch({ exit: 0, status: "exiting" })
this.close()
this.options.onExit?.()
return true
}
private printableBinding(binding: string, leader: string): string {
const first = Keybind.parse(binding).at(0)
if (!first) {
return ""
}
let text = Keybind.toString(first)
const lead = Keybind.parse(leader).at(0)
if (lead) {
text = text.replace("<leader>", Keybind.toString(lead))
}
text = text.replace(/escape/g, "esc")
return text
}
private handleDestroy = (): void => {
if (this.destroyed) {
return
}
this.destroyed = true
this.notifyClose()
this.clearInterruptTimer()
this.clearExitTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.prompts.clear()
this.closes.clear()
}
private scheduleSettleRender(): void {
if (this.settle || this.destroyed || this.renderer.isDestroyed) {
return
}
this.settle = true
void this.renderer
.idle()
.then(() => {
if (this.destroyed || this.renderer.isDestroyed || this.closed) {
return
}
this.renderer.requestRender()
})
.catch(() => {})
.finally(() => {
this.settle = false
})
}
}

View File

@@ -0,0 +1,625 @@
/** @jsxImportSource @opentui/solid */
import { StyledText, bg, fg, type KeyBinding } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { Show, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import "opentui-spinner/solid"
import { Keybind } from "../../../util/keybind"
import { createColors, createFrames } from "../tui/ui/spinner"
import type { FooterKeybinds, FooterState } from "./types"
import { RUN_THEME_FALLBACK, type RunFooterTheme } from "./theme"
const LEADER_TIMEOUT_MS = 2000
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
export const HINT_BREAKPOINTS = {
send: 50,
newline: 66,
history: 80,
variant: 95,
}
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
type History = {
items: string[]
index: number | null
draft: string
}
type Area = {
isDestroyed: boolean
virtualLineCount: number
visualCursor: {
visualRow: number
}
plainText: string
cursorOffset: number
setText(text: string): void
focus(): void
on(event: string, fn: () => void): void
off(event: string, fn: () => void): void
}
type Key = {
name: string
ctrl?: boolean
meta?: boolean
shift?: boolean
super?: boolean
hyper?: boolean
preventDefault(): void
}
type RunFooterViewProps = {
state: () => FooterState
theme?: RunFooterTheme
keybinds: FooterKeybinds
history?: string[]
agent: string
onSubmit: (text: string) => boolean
onCycle: () => void
onInterrupt: () => boolean
onExitRequest?: () => boolean
onExit: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
function isExitCommand(input: string): boolean {
const normalized = input.trim().toLowerCase()
return normalized === "/exit" || normalized === "/quit"
}
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
return Keybind.parse(binding).map((item) => ({
name: item.name,
ctrl: item.ctrl || undefined,
meta: item.meta || undefined,
shift: item.shift || undefined,
super: item.super || undefined,
action,
}))
}
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...mapInputBindings(keybinds.inputSubmit, "submit"),
...mapInputBindings(keybinds.inputNewline, "newline"),
]
}
function printableBinding(binding: string, leader: string): string {
const first = Keybind.parse(binding).at(0)
if (!first) {
return ""
}
let text = Keybind.toString(first)
const lead = Keybind.parse(leader).at(0)
if (lead) {
text = text.replace("<leader>", Keybind.toString(lead))
}
text = text.replace(/escape/g, "esc")
return text
}
function toKeyInfo(event: Key, leader: boolean): Keybind.Info {
return {
name: event.name === " " ? "space" : event.name,
ctrl: !!event.ctrl,
meta: !!event.meta,
shift: !!event.shift,
super: !!event.super,
leader,
}
}
function match(bindings: Keybind.Info[], event: Keybind.Info): boolean {
return bindings.some((item) => Keybind.match(item, event))
}
function clampRows(rows: number): number {
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
}
export function hintFlags(width: number) {
return {
send: width >= HINT_BREAKPOINTS.send,
newline: width >= HINT_BREAKPOINTS.newline,
history: width >= HINT_BREAKPOINTS.history,
variant: width >= HINT_BREAKPOINTS.variant,
}
}
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const leaders = createMemo(() => Keybind.parse(props.keybinds.leader))
const cycles = createMemo(() => Keybind.parse(props.keybinds.variantCycle))
const interrupts = createMemo(() => Keybind.parse(props.keybinds.interrupt))
const historyPrevious = createMemo(() => Keybind.parse(props.keybinds.historyPrevious))
const historyNext = createMemo(() => Keybind.parse(props.keybinds.historyNext))
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
const bindings = createMemo(() => textareaBindings(props.keybinds))
const hints = createMemo(() => hintFlags(term().width))
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
const queue = createMemo(() => props.state().queue)
const duration = createMemo(() => props.state().duration)
const usage = createMemo(() => props.state().usage)
const interruptKey = createMemo(() => interrupt() || "/exit")
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
const spin = createMemo(() => {
const list = [theme().highlight, theme().text, theme().muted]
return {
frames: createFrames({
colors: list,
style: "blocks",
}),
color: createColors({
colors: list,
defaultColor: theme().muted,
style: "blocks",
enableFading: false,
}),
}
})
const placeholder = createMemo(() => {
if (!props.state().first) {
return ""
}
return new StyledText([bg(theme().surface)(fg(theme().muted)('Ask anything... "Fix a TODO in the codebase"'))])
})
const history: History = {
items: (props.history ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
.filter((item, index, all) => index === 0 || item !== all[index - 1])
.slice(-200),
index: null,
draft: "",
}
let area: Area | undefined
let leader = false
let timeout: NodeJS.Timeout | undefined
let rowsTick = false
const clearLeader = () => {
leader = false
if (!timeout) {
return
}
clearTimeout(timeout)
timeout = undefined
}
const armLeader = () => {
clearLeader()
leader = true
timeout = setTimeout(() => {
clearLeader()
}, LEADER_TIMEOUT_MS)
}
const syncRows = () => {
if (!area || area.isDestroyed) {
return
}
props.onRows(clampRows(area.virtualLineCount || 1))
}
const scheduleRows = () => {
if (rowsTick) {
return
}
rowsTick = true
queueMicrotask(() => {
rowsTick = false
syncRows()
})
}
const push = (text: string) => {
if (!text) {
return
}
if (history.items[history.items.length - 1] === text) {
history.index = null
history.draft = ""
return
}
history.items.push(text)
if (history.items.length > 200) {
history.items.shift()
}
history.index = null
history.draft = ""
}
const move = (dir: -1 | 1, event: Key) => {
if (!area || history.items.length === 0) {
return
}
if (dir === -1 && area.cursorOffset !== 0) {
return
}
if (dir === 1 && area.cursorOffset !== area.plainText.length) {
return
}
if (history.index === null) {
if (dir === 1) {
return
}
history.draft = area.plainText
history.index = history.items.length - 1
} else {
const next = history.index + dir
if (next < 0) {
return
}
if (next >= history.items.length) {
history.index = null
area.setText(history.draft)
area.cursorOffset = area.plainText.length
event.preventDefault()
syncRows()
return
}
history.index = next
}
const next = history.items[history.index]
area.setText(next)
area.cursorOffset = dir === -1 ? 0 : area.plainText.length
event.preventDefault()
syncRows()
}
const handleCycle = (event: Key): boolean => {
const plain = toKeyInfo(event, false)
if (!leader && match(leaders(), plain)) {
armLeader()
event.preventDefault()
return true
}
if (leader) {
const key = toKeyInfo(event, true)
const hit = match(cycles(), key)
clearLeader()
event.preventDefault()
if (hit) {
props.onCycle()
}
return true
}
if (!match(cycles(), plain)) {
return false
}
props.onCycle()
event.preventDefault()
return true
}
const onKeyDown = (event: Key) => {
if (event.ctrl && event.name === "c") {
const handled = props.onExitRequest ? props.onExitRequest() : (props.onExit(), true)
if (handled) {
event.preventDefault()
}
return
}
if (match(interrupts(), toKeyInfo(event, false))) {
if (props.onInterrupt()) {
event.preventDefault()
return
}
}
if (handleCycle(event)) {
return
}
const key = toKeyInfo(event, false)
const previous = match(historyPrevious(), key)
const next = match(historyNext(), key)
if (!previous && !next) {
return
}
if (!area || area.isDestroyed) {
return
}
const dir = previous ? -1 : 1
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
move(dir, event)
return
}
if (dir === -1 && area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
}
const last =
"height" in area && typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
? area.height - 1
: Math.max(0, area.virtualLineCount - 1)
if (dir === 1 && area.visualCursor.visualRow === last) {
area.cursorOffset = area.plainText.length
}
}
const onSubmit = () => {
if (!area || area.isDestroyed) {
return
}
const text = area.plainText.trim()
if (!text) {
props.onStatus(props.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
if (isExitCommand(text)) {
props.onExit()
return
}
if (!props.onSubmit(text)) {
return
}
push(text)
area.setText("")
scheduleRows()
area.focus()
}
onMount(() => {
if (!area || area.isDestroyed) {
return
}
area.on("line-info-change", scheduleRows)
scheduleRows()
area.focus()
})
onCleanup(() => {
clearLeader()
if (!area || area.isDestroyed) {
return
}
area.off("line-info-change", scheduleRows)
})
createEffect(() => {
term().width
scheduleRows()
})
createEffect(() => {
props.state().phase
if (!area || area.isDestroyed || props.state().phase !== "idle") {
return
}
queueMicrotask(() => {
if (!area || area.isDestroyed) {
return
}
area.focus()
})
})
return (
<box
id="run-direct-footer-shell"
width="100%"
height="100%"
border={false}
backgroundColor="transparent"
flexDirection="column"
gap={0}
padding={0}
>
<box
id="run-direct-footer-composer-frame"
width="100%"
flexShrink={0}
border={["left"]}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
id="run-direct-footer-composer-area"
width="100%"
flexGrow={1}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexDirection="column"
backgroundColor={theme().surface}
gap={0}
>
<textarea
id="run-direct-footer-composer"
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={placeholder()}
placeholderColor={theme().muted}
textColor={theme().text}
focusedTextColor={theme().text}
backgroundColor={theme().surface}
focusedBackgroundColor={theme().surface}
cursorColor={theme().text}
keyBindings={bindings()}
onSubmit={onSubmit}
onKeyDown={onKeyDown}
onContentChange={scheduleRows}
ref={(item) => {
area = item as Area
}}
/>
<box id="run-direct-footer-meta-row" width="100%" flexDirection="row" gap={1} flexShrink={0} paddingTop={1}>
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
{props.agent}
</text>
<text id="run-direct-footer-model" fg={theme().muted} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{props.state().model}
</text>
</box>
</box>
</box>
<box
id="run-direct-footer-line-6"
width="100%"
height={1}
border={["left"]}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
}}
flexShrink={0}
>
<box
id="run-direct-footer-line-6-fill"
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().line}
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
}}
/>
</box>
<box
id="run-direct-footer-row"
width="100%"
height={1}
flexDirection="row"
justifyContent="space-between"
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting()}>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
Press Ctrl-c again to exit
</text>
</Show>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
<text
id="run-direct-footer-hint-interrupt"
fg={armed() ? theme().highlight : theme().text}
wrapMode="none"
truncate
>
{interruptKey()}{" "}
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
{armed() ? "again to interrupt" : "interrupt"}
</span>
</text>
</Show>
</box>
</Show>
<Show when={!busy() && !exiting() && duration().length > 0}>
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
</text>
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
·
</text>
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
{duration()}
</text>
</box>
</box>
</Show>
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={variant().length > 0 && hints().variant}>
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
{variant()} variant
</text>
</Show>
</box>
</box>
</box>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
import {
TextAttributes,
TextRenderable,
type ColorInput,
type ScrollbackRenderContext,
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import { RUN_THEME_FALLBACK, type RunEntryTheme } from "./theme"
import type { StreamCommit } from "./types"
type Paint = {
fg: ColorInput
attributes?: number
}
let id = 0
function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
const kind = commit.kind
if (kind === "user") {
return {
fg: theme.user.body,
attributes: TextAttributes.BOLD,
}
}
if (commit.phase === "final") {
return {
fg: theme.system.body,
attributes: TextAttributes.DIM,
}
}
if (kind === "assistant") {
return {
fg: theme.assistant.body,
}
}
if (kind === "reasoning") {
return {
fg: theme.reasoning.body,
attributes: TextAttributes.DIM,
}
}
if (kind === "error") {
return {
fg: theme.error.body,
attributes: TextAttributes.BOLD,
}
}
if (kind === "tool") {
return {
fg: theme.tool.body,
}
}
return {
fg: theme.system.body,
}
}
export function normalizeEntry(commit: StreamCommit): string {
const raw = commit.text.replace(/\r/g, "")
const kind = commit.kind
if (kind === "user") {
if (!raw.trim()) {
return ""
}
return ` ${raw}`
}
if (commit.phase === "start" || commit.phase === "final") {
return raw.trim()
}
if (kind === "assistant") {
// Preserve body formatting for progress
return raw
}
if (kind === "reasoning") {
const body = raw.replace(/\[REDACTED\]/g, "")
// Keep reasoning raw unless we need special block formatting, but for now we preserve
return body
}
if (kind === "error") {
return raw
}
return raw
}
function build(commit: StreamCommit, ctx: ScrollbackRenderContext, theme: RunEntryTheme): ScrollbackSnapshot {
const body = normalizeEntry(commit)
const width = Math.max(1, ctx.width)
const style = look(commit, theme)
const startOnNewLine = commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const trailingNewline = commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const root = new TextRenderable(ctx.renderContext, {
id: `run-direct-entry-${id++}`,
position: "absolute",
left: 0,
top: 0,
width,
height: 1,
content: body,
wrapMode: "word",
fg: style.fg,
attributes: style.attributes,
})
const height = Math.max(1, root.scrollHeight)
root.height = height
return {
root,
width,
height,
rowColumns: width,
startOnNewLine,
trailingNewline,
}
}
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(commit: StreamCommit, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
return (ctx) => build(commit, 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,395 @@
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../../util/locale"
import type { StreamCommit } from "./types"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
type Tokens = {
input?: number
output?: number
reasoning?: number
cache?: {
read?: number
write?: number
}
}
type PartKind = "assistant" | "reasoning"
export type SessionCommit = StreamCommit
export type SessionData = {
ids: Set<string>
tools: Set<string>
announced: boolean
text: Map<string, string>
sent: Map<string, number>
part: Map<string, PartKind>
}
export type SessionDataInput = {
data: SessionData
event: Event
sessionID: string
thinking: boolean
limits: Record<string, number>
}
export type SessionDataOutput = {
data: SessionData
commits: SessionCommit[]
status?: string
usage?: string
}
export function createSessionData(): SessionData {
return {
ids: new Set(),
tools: new Set(),
announced: false,
text: new Map(),
sent: new Map(),
part: new Map(),
}
}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
}
function formatUsage(
tokens: Tokens | undefined,
limit: number | undefined,
cost: number | undefined,
): string | undefined {
const total =
(tokens?.input ?? 0) +
(tokens?.output ?? 0) +
(tokens?.reasoning ?? 0) +
(tokens?.cache?.read ?? 0) +
(tokens?.cache?.write ?? 0)
if (total <= 0) {
if (typeof cost === "number" && cost > 0) {
return money.format(cost)
}
return
}
const text =
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
if (typeof cost === "number" && cost > 0) {
return `${text} · ${money.format(cost)}`
}
return text
}
function formatSessionError(error: {
name: string
data?: {
message?: string
}
}): string {
if (error.data?.message) {
return String(error.data.message)
}
return String(error.name)
}
function toolStatus(part: ToolPart): string {
if (part.tool !== "task") {
return `running ${part.tool}`
}
const state = part.state as {
input?: {
description?: unknown
subagent_type?: unknown
}
}
const desc = state.input?.description
if (typeof desc === "string" && desc.trim()) {
return `running ${desc.trim()}`
}
const type = state.input?.subagent_type
if (typeof type === "string" && type.trim()) {
return `running ${type.trim()}`
}
return "running task"
}
export function flushPart(
data: SessionData,
commits: SessionCommit[],
partID: string,
end: boolean,
interrupted: boolean = false,
) {
const kind = data.part.get(partID)
if (!kind) return
const text = data.text.get(partID) ?? ""
const sent = data.sent.get(partID) ?? 0
const chunk = text.slice(sent)
if (chunk) {
data.sent.set(partID, text.length)
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: chunk,
phase: "progress",
source: kind,
partID,
})
}
if (!end && !interrupted) return
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: interrupted ? `[${kind}:interrupted]` : `[${kind}:end]`,
phase: "final",
source: kind,
partID,
})
}
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
for (const partID of data.part.keys()) {
if (!data.ids.has(partID)) {
flushPart(data, commits, partID, false, true)
}
}
}
function out(data: SessionData, commits: SessionCommit[], status?: string, usage?: string): SessionDataOutput {
const next: SessionDataOutput = {
data,
commits,
}
if (typeof status === "string") {
next.status = status
}
if (typeof usage === "string") {
next.usage = usage
}
return next
}
export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const commits: SessionCommit[] = []
const data = input.data
const event = input.event
if (event.type === "message.updated") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const info = event.properties.info
if (info.role !== "assistant") {
return out(data, commits)
}
const status = data.announced ? undefined : "assistant responding"
data.announced = true
const usage = formatUsage(
info.tokens,
input.limits[modelKey(info.providerID, info.modelID)],
typeof info.cost === "number" ? info.cost : undefined,
)
return out(data, commits, status, usage)
}
if (event.type === "message.part.delta") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
if (
typeof event.properties.partID !== "string" ||
typeof event.properties.field !== "string" ||
typeof event.properties.delta !== "string"
) {
return out(data, commits)
}
if (event.properties.field !== "text") {
return out(data, commits)
}
const partID = event.properties.partID
if (data.ids.has(partID)) {
return out(data, commits)
}
const current = data.text.get(partID) ?? ""
data.text.set(partID, current + event.properties.delta)
const kind = data.part.get(partID)
if (kind) {
flushPart(data, commits, partID, false)
}
return out(data, commits)
}
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== input.sessionID) {
return out(data, commits)
}
if (part.type === "tool" && part.state.status === "running") {
if (data.ids.has(part.id)) {
return out(data, commits)
}
if (data.tools.has(part.id)) {
return out(data, commits)
}
data.tools.add(part.id)
commits.push({
kind: "tool",
text: `[tool:${part.tool}] ${toolStatus(part)}`,
phase: "start",
source: "tool",
partID: part.id,
tool: part.tool,
})
return out(data, commits, toolStatus(part))
}
if (part.type === "tool" && part.state.status === "completed") {
data.tools.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits)
}
data.ids.add(part.id)
const output = part.state.output
if (typeof output === "string" && output.trim()) {
commits.push({
kind: "tool",
text: output,
phase: "progress",
source: "tool",
partID: part.id,
tool: part.tool,
})
}
commits.push({
kind: "tool",
text: `[tool:${part.tool}:end]`,
phase: "final",
source: "tool",
partID: part.id,
tool: part.tool,
})
return out(data, commits)
}
if (part.type === "tool" && part.state.status === "error") {
data.tools.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits)
}
data.ids.add(part.id)
const errorText = part.state.error ?? "unknown error"
commits.push({
kind: "tool",
text: `[tool:${part.tool}:error] ${errorText}`,
phase: "final",
source: "tool",
partID: part.id,
tool: part.tool,
})
return out(data, commits)
}
if (part.type === "text" || part.type === "reasoning") {
if (data.ids.has(part.id)) {
return out(data, commits)
}
if (part.type === "reasoning" && !input.thinking) {
if (part.time?.end) {
data.ids.add(part.id)
data.text.delete(part.id)
}
return out(data, commits)
}
const kind = part.type === "text" ? "assistant" : "reasoning"
const wasKnown = data.part.has(part.id)
if (!wasKnown) {
data.part.set(part.id, kind)
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: `[${kind}]`,
phase: "start",
source: kind,
partID: part.id,
})
}
data.text.set(part.id, part.text)
flushPart(data, commits, part.id, !!part.time?.end)
if (part.time?.end) {
data.ids.add(part.id)
data.part.delete(part.id)
data.text.delete(part.id)
data.sent.delete(part.id)
}
return out(data, commits)
}
return out(data, commits)
}
if (event.type === "permission.asked") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
return out(
data,
commits,
`permission requested: ${event.properties.permission} (${event.properties.patterns.join(", ")}); auto-rejecting`,
)
}
if (event.type === "session.error") {
if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
return out(data, commits)
}
commits.push({
kind: "error",
text: formatSessionError(event.properties.error),
phase: "start",
source: "system",
})
return out(data, commits)
}
return out(data, commits)
}

View File

@@ -0,0 +1,251 @@
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
showSession?: boolean
}
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
if (input.showSession !== false) {
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

@@ -0,0 +1,179 @@
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, flushInterrupted, type SessionCommit } from "./session-data"
import type { FooterApi, RunFilePart, RunInput } from "./types"
type TurnInput = {
sdk: OpencodeClient
sessionID: string
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
prompt: string
files: RunFilePart[]
includeFiles: boolean
thinking: boolean
limits: Record<string, number>
footer: FooterApi
signal?: AbortSignal
}
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") {
return error
}
if (error instanceof Error) {
return error.message || error.name
}
if (error && typeof error === "object") {
const candidate = error as { message?: unknown; name?: unknown }
if (typeof candidate.message === "string" && candidate.message.trim().length > 0) {
return candidate.message
}
if (typeof candidate.name === "string" && candidate.name.trim().length > 0) {
return candidate.name
}
}
return "unknown error"
}
export async function runPromptTurn(input: TurnInput): Promise<void> {
if (input.signal?.aborted) {
return
}
const abort = new AbortController()
const stop = () => {
abort.abort()
}
input.signal?.addEventListener("abort", stop, { once: true })
let events: Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>
try {
events = await input.sdk.event.subscribe(undefined, {
signal: abort.signal,
})
} catch (error) {
input.signal?.removeEventListener("abort", stop)
throw error
}
const close = () => {
// Pass undefined explicitly so TS accepts AsyncGenerator.return().
void events.stream.return(undefined).catch(() => { })
}
let data = createSessionData()
const watch = (async () => {
try {
for await (const item of events.stream) {
if (input.footer.isClosed) {
break
}
const event = item as Event
const next = reduceSessionData({
data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits,
})
data = next.data
for (const commit of next.commits) {
input.footer.append(commit)
}
if (next.status) {
input.footer.patch({
phase: "running",
status: next.status,
})
}
if (next.usage) {
input.footer.patch({
usage: next.usage,
})
}
if (
event.type === "session.status" &&
event.properties.sessionID === input.sessionID &&
event.properties.status.type === "idle"
) {
break
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== input.sessionID) continue
await input.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
}
}
} catch (error) {
if (!abort.signal.aborted) {
throw error
}
}
})()
try {
await input.sdk.session.prompt(
{
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
variant: input.variant,
parts: [...(input.includeFiles ? input.files : []), { type: "text", text: input.prompt }],
},
{
signal: abort.signal,
},
)
if (abort.signal.aborted) {
const commits: SessionCommit[] = []
flushInterrupted(data, commits)
for (const commit of commits) {
input.footer.append(commit)
}
return
}
if (!input.footer.isClosed && !data.announced) {
input.footer.patch({
phase: "running",
status: "waiting for assistant",
})
}
await watch
} catch (error) {
const canceled = abort.signal.aborted || input.signal?.aborted === true
abort.abort()
if (canceled) {
close()
const commits: SessionCommit[] = []
flushInterrupted(data, commits)
for (const commit of commits) {
input.footer.append(commit)
}
void watch.catch(() => { })
return
}
await watch.catch(() => { })
throw error
} finally {
close()
input.signal?.removeEventListener("abort", stop)
abort.abort()
}
}

View File

@@ -0,0 +1,144 @@
import { RGBA, type CliRenderer, type ColorInput } from "@opentui/core"
import type { EntryKind } from "./types"
type Tone = {
body: ColorInput
}
export type RunEntryTheme = Record<EntryKind, Tone>
export type RunFooterTheme = {
highlight: ColorInput
muted: ColorInput
text: ColorInput
surface: ColorInput
line: ColorInput
}
export type RunTheme = {
background: ColorInput
footer: RunFooterTheme
entry: RunEntryTheme
}
type Resolved = {
background: RGBA
backgroundElement: RGBA
primary: RGBA
warning: RGBA
error: RGBA
text: RGBA
textMuted: RGBA
}
function alpha(color: RGBA, value: number): RGBA {
const a = Math.max(0, Math.min(1, value))
return RGBA.fromValues(color.r, color.g, color.b, a)
}
function rgba(hex: string, value?: number): RGBA {
const color = RGBA.fromHex(hex)
if (value === undefined) {
return color
}
return alpha(color, value)
}
function mode(bg: RGBA): "dark" | "light" {
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
if (lum > 0.5) {
return "light"
}
return "dark"
}
function map(theme: Resolved): RunTheme {
const pane = theme.backgroundElement
const surface = alpha(pane, pane.a === 0 ? 0.18 : Math.min(0.9, pane.a * 0.88))
const line = alpha(pane, pane.a === 0 ? 0.24 : Math.min(0.98, pane.a * 0.96))
return {
background: theme.background,
footer: {
highlight: theme.primary,
muted: theme.textMuted,
text: theme.text,
surface,
line,
},
entry: {
system: {
body: theme.textMuted,
},
user: {
body: theme.primary,
},
assistant: {
body: theme.text,
},
reasoning: {
body: theme.textMuted,
},
tool: {
body: theme.warning,
},
error: {
body: theme.error,
},
},
}
}
const seed = {
highlight: rgba("#38bdf8"),
muted: rgba("#64748b"),
text: rgba("#f8fafc"),
panel: rgba("#0f172a"),
warning: rgba("#f59e0b"),
error: rgba("#ef4444"),
}
function tone(body: ColorInput): Tone {
return {
body,
}
}
export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
muted: seed.muted,
text: seed.text,
surface: alpha(seed.panel, 0.86),
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.warning),
error: tone(seed.error),
},
}
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
try {
const colors = await renderer.getPalette({
size: 16,
})
const bg = colors.defaultBackground ?? colors.palette[0]
if (!bg) {
return RUN_THEME_FALLBACK
}
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
const mod = await import("../tui/context/theme")
return map(mod.resolveTheme(mod.generateSystem(colors, pick), pick) as Resolved)
} catch {
return RUN_THEME_FALLBACK
}
}

View File

@@ -0,0 +1,74 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
export type RunFilePart = {
type: "file"
url: string
filename: string
mime: string
}
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
files: RunFilePart[]
initialInput?: string
thinking: boolean
}
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
export type FooterPhase = "idle" | "running"
export type FooterState = {
phase: FooterPhase
status: string
queue: number
model: string
duration: string
usage: string
first: boolean
interrupt: number
exit: number
}
export type FooterPatch = Partial<FooterState>
export type FooterKeybinds = {
leader: string
variantCycle: string
interrupt: string
historyPrevious: string
historyNext: string
inputSubmit: string
inputNewline: string
}
export type StreamPhase = "start" | "progress" | "final"
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamCommit = {
kind: EntryKind
text: string
phase: StreamPhase
source: StreamSource
partID?: string
tool?: string
}
export type FooterApi = {
readonly isClosed: boolean
onPrompt(fn: (text: string) => void): () => void
onClose(fn: () => void): () => void
patch(next: FooterPatch): void
append(commit: StreamCommit): void
close(): void
destroy(): void
}

View File

@@ -251,6 +251,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()

View File

@@ -509,7 +509,9 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
// TODO: i exported this, just for keeping it simple for now, but this should
// probably go into something shared if we decide to use this in opencode run
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)

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: "Build",
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

@@ -0,0 +1,452 @@
import { describe, expect, test } from "bun:test"
import { pickVariant, queueSplash, resolveVariant, runPromptQueue } from "../../../src/cli/cmd/run/runtime"
import type { FooterApi, FooterPatch, StreamCommit } from "../../../src/cli/cmd/run/types"
function createFooter() {
const prompts = new Set<(text: string) => void>()
const closes = new Set<() => void>()
const patched: FooterPatch[] = []
const appended: StreamCommit[] = []
let closed = false
const close = () => {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
}
const footer: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
patch(next) {
patched.push(next)
},
append(commit) {
appended.push(commit)
},
close,
destroy() {
close()
prompts.clear()
closes.clear()
},
}
return {
footer,
patched,
appended,
listeners() {
return {
prompts: prompts.size,
closes: closes.size,
}
},
submit(text: string) {
for (const fn of [...prompts]) {
fn(text)
}
},
close,
}
}
describe("run runtime", () => {
test("restores variant from latest matching user message", () => {
expect(
pickVariant(
{
providerID: "openai",
modelID: "gpt-5",
},
[
{
info: {
role: "user",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: "high",
},
},
{
info: {
role: "user",
model: {
providerID: "anthropic",
modelID: "claude-3",
},
variant: "max",
},
},
{
info: {
role: "user",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: "minimal",
},
},
] as unknown as Parameters<typeof pickVariant>[1],
),
).toBe("minimal")
})
test("respects default variant from latest matching user message", () => {
expect(
pickVariant(
{
providerID: "openai",
modelID: "gpt-5",
},
[
{
info: {
role: "user",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: "high",
},
},
{
info: {
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
},
},
{
info: {
role: "user",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
},
] as unknown as Parameters<typeof pickVariant>[1],
),
).toBeUndefined()
})
test("keeps saved variant when session variant is default", () => {
expect(resolveVariant(undefined, undefined, "high", ["high", "minimal"])).toBe("high")
})
test("session variant overrides saved variant", () => {
expect(resolveVariant(undefined, "minimal", "high", ["high", "minimal"])).toBe("minimal")
})
test("cli variant overrides session and saved variant", () => {
expect(resolveVariant("custom", "minimal", "high", ["high", "minimal"])).toBe("custom")
})
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
ui.close()
await runPromptQueue({
footer: ui.footer,
run: async () => {
calls += 1
},
})
expect(calls).toBe(0)
expect(ui.listeners()).toEqual({ prompts: 0, closes: 0 })
})
test("close resolves queue and unsubscribes listeners", async () => {
const ui = createFooter()
const queue = runPromptQueue({
footer: ui.footer,
run: async () => {},
})
expect(ui.listeners()).toEqual({ prompts: 1, closes: 1 })
ui.close()
await queue
expect(ui.listeners()).toEqual({ prompts: 0, closes: 0 })
})
test("submit while running is queued", async () => {
const ui = createFooter()
const prompts: string[] = []
let resume: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
resume = resolve
})
const queue = runPromptQueue({
footer: ui.footer,
run: async (prompt) => {
prompts.push(prompt)
if (prompts.length === 1) {
await gate
}
},
})
ui.submit("one")
ui.submit("two")
expect(prompts).toEqual(["one"])
expect(ui.patched).toContainEqual({ queue: 1 })
ui.close()
resume?.()
await queue
})
test("queued prompts run in order", async () => {
const ui = createFooter()
const prompts: string[] = []
let resume: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
resume = resolve
})
let done: (() => void) | undefined
const seen = new Promise<void>((resolve) => {
done = resolve
})
const queue = runPromptQueue({
footer: ui.footer,
run: async (prompt) => {
prompts.push(prompt)
if (prompts.length === 1) {
await gate
return
}
done?.()
},
})
ui.submit("one")
ui.submit("two")
resume?.()
await seen
ui.close()
await queue
expect(prompts).toEqual(["one", "two"])
expect(ui.appended).toEqual([
{ kind: "user", text: "one", phase: "start", source: "system" },
{ kind: "user", text: "two", phase: "start", source: "system" },
])
})
test("close stops pending queued work", async () => {
const ui = createFooter()
const prompts: string[] = []
let resume: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
resume = resolve
})
const queue = runPromptQueue({
footer: ui.footer,
run: async (prompt) => {
prompts.push(prompt)
if (prompts.length === 1) {
await gate
}
},
})
ui.submit("one")
ui.submit("two")
ui.close()
resume?.()
await queue
expect(prompts).toEqual(["one"])
expect(ui.appended).toEqual([{ kind: "user", text: "one", phase: "start", source: "system" }])
expect(ui.patched).toContainEqual({
phase: "idle",
status: "",
queue: 0,
})
})
test("close aborts active run signal", async () => {
const ui = createFooter()
let hit = false
const queue = runPromptQueue({
footer: ui.footer,
run: async (_, signal) => {
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
resolve()
},
{ once: true },
)
})
},
})
ui.submit("one")
ui.close()
await queue
expect(hit).toBe(true)
})
test("close resolves even when run ignores abort", async () => {
const ui = createFooter()
const queue = runPromptQueue({
footer: ui.footer,
run: async () => {
await new Promise<void>(() => {})
},
})
ui.submit("one")
ui.close()
const result = await Promise.race([
queue.then(() => "done" as const),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
])
expect(result).toBe("done")
})
test("keeps initial input whitespace", async () => {
const ui = createFooter()
const prompts: string[] = []
await runPromptQueue({
footer: ui.footer,
initialInput: " hello ",
run: async (prompt) => {
prompts.push(prompt)
ui.close()
},
})
expect(prompts).toEqual([" hello "])
expect(ui.appended).toEqual([{ kind: "user", text: " hello ", phase: "start", source: "system" }])
})
test("treats initial /exit as close command", async () => {
const ui = createFooter()
let calls = 0
await runPromptQueue({
footer: ui.footer,
initialInput: "/exit",
run: async () => {
calls += 1
},
})
expect(calls).toBe(0)
expect(ui.appended).toEqual([])
})
test("records last turn duration", async () => {
const ui = createFooter()
await runPromptQueue({
footer: ui.footer,
initialInput: "one",
run: async () => {
await new Promise((resolve) => setTimeout(resolve, 5))
ui.close()
},
})
const duration = ui.patched.find((item) => typeof item.duration === "string")?.duration
expect(typeof duration).toBe("string")
expect(duration?.length ?? 0).toBeGreaterThan(0)
})
test("propagates errors from prompt callbacks", async () => {
const ui = createFooter()
const queue = runPromptQueue({
footer: ui.footer,
run: async () => {
throw new Error("boom")
},
})
ui.submit("one")
await expect(queue).rejects.toThrow("boom")
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
/** @jsxImportSource @opentui/solid */
import { afterEach, describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RunFooterView, hintFlags } from "../../../src/cli/cmd/run/footer.view"
import type { FooterState } from "../../../src/cli/cmd/run/types"
function get(node: any, id: string): any {
if (node.id === id) {
return node
}
if (typeof node.getChildren !== "function") {
return
}
for (const child of node.getChildren()) {
const found = get(child, id)
if (found) {
return found
}
}
}
function composer(setup: Awaited<ReturnType<typeof testRender>>) {
const node = get(setup.renderer.root, "run-direct-footer-composer")
if (!node) {
throw new Error("composer not found")
}
return node as {
plainText: string
cursorOffset: number
}
}
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
if (!setup) {
return
}
setup.renderer.destroy()
setup = undefined
})
describe("run footer view", () => {
test("submit key path emits prompts", async () => {
const sent: string[] = []
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={(text) => {
sent.push(text)
return true
}}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={(text) => {
setState((state) => ({
...state,
status: text,
}))
}}
/>
),
{
width: 110,
height: 12,
},
)
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
expect(sent).toEqual(["hello"])
})
test("history up down keeps edge behavior", async () => {
const sent: string[] = []
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={(text) => {
sent.push(text)
return true
}}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={(text) => {
setState((state) => ({
...state,
status: text,
}))
}}
/>
),
{
width: 110,
height: 12,
},
)
await setup.mockInput.typeText("one")
setup.mockInput.pressEnter()
await setup.mockInput.typeText("two")
setup.mockInput.pressEnter()
const area = composer(setup)
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("two")
expect(area.cursorOffset).toBe(0)
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("one")
expect(area.cursorOffset).toBe(0)
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("one")
expect(area.cursorOffset).toBe(0)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("one")
expect(area.cursorOffset).toBe(area.plainText.length)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("two")
expect(area.cursorOffset).toBe(area.plainText.length)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("")
expect(area.cursorOffset).toBe(0)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("")
expect(area.cursorOffset).toBe(0)
expect(sent).toEqual(["one", "two"])
})
test("history includes prior session prompts", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
history={["first", "second"]}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={(text) => {
setState((state) => ({
...state,
status: text,
}))
}}
/>
),
{
width: 110,
height: 12,
},
)
const area = composer(setup)
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("second")
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("first")
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("first")
expect(area.cursorOffset).toBe(area.plainText.length)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("second")
expect(area.cursorOffset).toBe(area.plainText.length)
setup.mockInput.pressArrow("down")
expect(area.plainText).toBe("")
expect(area.cursorOffset).toBe(0)
})
test("hint visibility matches width breakpoints", () => {
expect(hintFlags(49)).toEqual({
send: false,
newline: false,
history: false,
variant: false,
})
expect(hintFlags(50)).toEqual({
send: true,
newline: false,
history: false,
variant: false,
})
expect(hintFlags(66)).toEqual({
send: true,
newline: true,
history: false,
variant: false,
})
expect(hintFlags(80)).toEqual({
send: true,
newline: true,
history: true,
variant: false,
})
expect(hintFlags(95)).toEqual({
send: true,
newline: true,
history: true,
variant: true,
})
})
test("placeholder switches after first prompt", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain('Ask anything... "Fix a TODO in the codebase"')
setState((state) => ({
...state,
first: false,
}))
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Ask anything...")
expect(setup.captureCharFrame()).not.toContain("Fix a TODO in the codebase")
})
test("baseline scaffold follows 6-line layout", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "gpt-5.3-codex · openai",
duration: "1m 18s",
usage: "167.8K (42%)",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
const lines = setup.captureCharFrame().split("\n")
expect(lines[0]).toMatch(/^┃\s*$/)
expect(lines[1]?.startsWith("┃")).toBe(true)
expect(lines[1]).toContain('Ask anything... "Fix a TODO in the codebase"')
expect(lines[2]).toMatch(/^┃\s*$/)
expect(lines[3]?.startsWith("┃")).toBe(true)
expect(lines[3]).toContain("Build")
expect(lines[4]).toMatch(/^╹▀+$/)
expect(lines[5]).not.toContain("interrupt")
expect(lines[5]).toContain("▣ · 1m 18s")
expect(lines[5]).toContain("167.8K (42%)")
expect(lines[5]).toContain("ctrl+t variant")
})
test("renders usage and duration fields", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "1m 18s",
usage: "167.8K (42%)",
first: false,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("▣ · 1m 18s")
expect(frame).toContain("167.8K (42%)")
})
test("interrupt hint reflects running escape state", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "assistant responding",
queue: 0,
model: "model",
duration: "",
usage: "",
first: false,
interrupt: 1,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("esc again to interrupt")
})
test("duration marker hides when interrupt or exit hints are active", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "1m 18s",
usage: "",
first: false,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("▣ · 1m 18s")
setState((state) => ({
...state,
phase: "running",
}))
await setup.renderOnce()
const running = setup.captureCharFrame()
expect(running).toContain("interrupt")
expect(running).not.toContain("▣ · 1m 18s")
setState((state) => ({
...state,
phase: "idle",
exit: 1,
}))
await setup.renderOnce()
const exiting = setup.captureCharFrame()
expect(exiting).toContain("Press Ctrl-c again to exit")
expect(exiting).not.toContain("▣ · 1m 18s")
})
test("ctrl-c exit hint appears when armed", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: false,
interrupt: 0,
exit: 1,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Press Ctrl-c again to exit")
})
test("queued indicator appears when queue is nonzero", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
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",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 110,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("queued")
setState((state) => ({
...state,
queue: 2,
}))
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("2 queued")
})
})

View File

@@ -0,0 +1,194 @@
import { describe, expect, test } from "bun:test"
import { TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { blockWriter, entryWriter, normalizeEntry } from "../../../src/cli/cmd/run/scrollback"
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
import type { StreamCommit } from "../../../src/cli/cmd/run/types"
function make(kind: StreamCommit["kind"], text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
return {
kind,
text,
phase,
source:
kind === "assistant" ? "assistant" : kind === "reasoning" ? "reasoning" : kind === "tool" ? "tool" : "system",
}
}
async function draw(commit: StreamCommit) {
const setup = await testRender(() => null, {
width: 80,
height: 12,
})
try {
const snap = entryWriter(
commit,
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()
}
}
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)
}
return a === b
}
describe("run scrollback", () => {
test("renders progress entries inline by default", async () => {
const out = await draw(make("assistant", "assistant reply"))
expect(out.root.constructor.name).toBe("TextRenderable")
expect(out.text).toBe("assistant reply")
expect(out.text).not.toContain("ASSISTANT")
expect(out.text).not.toMatch(/\b\d{2}:\d{2}:\d{2}\b/)
expect(out.text).not.toMatch(/[│┃┆┇┊┋╹╻╺╸]/)
expect(out.snap.width).toBe(80)
expect(out.snap.rowColumns).toBe(80)
expect(out.snap.startOnNewLine).toBe(false)
expect(out.snap.trailingNewline).toBe(false)
})
test("renders marker entries without extra blank rows", async () => {
const out = await draw(make("assistant", "[assistant]", "start"))
expect(out.text).toBe("[assistant]")
expect(out.snap.height).toBe(1)
expect(out.snap.startOnNewLine).toBe(true)
expect(out.snap.trailingNewline).toBe(true)
})
test("adds user marker and keeps whitespace", async () => {
const out = await draw(make("user", " one \r\n\t two\t\r\n", "start"))
expect(out.text).toBe(" one \n\t two\t\n")
expect(out.snap.startOnNewLine).toBe(true)
expect(out.snap.trailingNewline).toBe(true)
})
test("normalizes blank user input to empty", () => {
expect(normalizeEntry(make("user", " \r\n\t", "start"))).toBe("")
})
test("preserves assistant and error multiline content", async () => {
const assistant = await draw(make("assistant", "\nfirst\nsecond\n"))
expect(assistant.text).toBe("\nfirst\nsecond\n")
expect(assistant.snap.startOnNewLine).toBe(false)
expect(assistant.snap.trailingNewline).toBe(false)
const error = await draw(make("error", " failed\nwith detail ", "start"))
expect(error.text).toBe("failed\nwith detail")
expect(error.snap.startOnNewLine).toBe(true)
expect(error.snap.trailingNewline).toBe(true)
})
test("preserves whitespace-only progress chunks", async () => {
const out = await draw(make("assistant", " "))
expect(out.text).toBe(" ")
expect(out.snap.startOnNewLine).toBe(false)
expect(out.snap.trailingNewline).toBe(false)
})
test("formats reasoning text with redaction cleanup", async () => {
const out = await draw(make("reasoning", " [REDACTED]step\nnext "))
expect(out.text).toBe(" step\nnext ")
const prefixed = await draw(make("reasoning", "Thinking: keep\ngoing"))
expect(prefixed.text).toBe("Thinking: keep\ngoing")
})
test("wraps long assistant lines without clipping content", async () => {
const text =
"The sky was a deep shade of indigo as the stars began to emerge. A gentle breeze rustled through the trees, carrying whispers of rain."
const out = await draw(make("assistant", text))
expect(out.text).toBe(text)
expect(out.snap.height).toBeGreaterThan(1)
})
test("applies style mapping by entry phase and kind", async () => {
const user = await draw(make("user", "u", "start"))
const assistant = await draw(make("assistant", "a"))
const reasoning = await draw(make("reasoning", "r"))
const error = await draw(make("error", "e", "start"))
const final = await draw(make("assistant", "[assistant:end]", "final"))
expect(same(user.fg, RUN_THEME_FALLBACK.entry.user.body)).toBe(true)
expect(Boolean(user.attrs & TextAttributes.BOLD)).toBe(true)
expect(same(assistant.fg, RUN_THEME_FALLBACK.entry.assistant.body)).toBe(true)
expect(Boolean(assistant.attrs & TextAttributes.BOLD)).toBe(false)
expect(same(reasoning.fg, RUN_THEME_FALLBACK.entry.reasoning.body)).toBe(true)
expect(Boolean(reasoning.attrs & TextAttributes.DIM)).toBe(true)
expect(same(error.fg, RUN_THEME_FALLBACK.entry.error.body)).toBe(true)
expect(Boolean(error.attrs & TextAttributes.BOLD)).toBe(true)
expect(same(final.fg, RUN_THEME_FALLBACK.entry.system.body)).toBe(true)
expect(Boolean(final.attrs & TextAttributes.DIM)).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,393 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData } from "../../../src/cli/cmd/run/session-data"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = false) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
describe("session data reducer", () => {
test("repeated finalized part commits once", () => {
const evt = {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "assistant reply",
time: { end: Date.now() },
},
},
}
let data = createSessionData()
const first = reduce(data, evt)
expect(first.commits).toEqual([{ kind: "assistant", text: "assistant reply" }])
data = first.data
const next = reduce(data, evt)
expect(next.commits).toEqual([])
})
test("delta then final update emits one commit", () => {
let data = createSessionData()
const delta = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: "from delta",
},
})
data = delta.data
const final = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "",
time: { end: Date.now() },
},
},
})
expect(final.commits).toEqual([{ kind: "assistant", text: "from delta" }])
})
test("duplicate deltas keep finalized text", () => {
let data = createSessionData()
data = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: "hello",
},
}).data
data = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: "hello",
},
}).data
const out = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "hello",
time: { end: Date.now() },
},
},
})
expect(out.commits).toEqual([{ kind: "assistant", text: "hello" }])
})
test("ignores non-text deltas", () => {
const out = reduce(createSessionData(), {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "input",
delta: "ignored",
},
})
expect(out.commits).toEqual([])
expect(out.data.delta.size).toBe(0)
})
test("ignores stale deltas after part finalized", () => {
let data = createSessionData()
data = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "done",
time: { end: Date.now() },
},
},
}).data
const out = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "txt-1",
field: "text",
delta: "late",
},
})
expect(out.commits).toEqual([])
expect(out.data.delta.size).toBe(0)
})
test("tool running then completed success stays status-only", () => {
let data = createSessionData()
const running = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "running",
input: {
description: "investigate",
},
},
},
},
})
expect(running.commits).toEqual([])
expect(running.status).toBe("running investigate")
data = running.data
const done = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "completed",
input: {},
output: "ok",
title: "task",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
})
expect(done.commits).toEqual([])
})
test("replayed running tool after completion is ignored", () => {
let data = createSessionData()
data = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "running",
input: {
description: "investigate",
},
},
},
},
}).data
data = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "completed",
input: {},
output: "ok",
title: "task",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
}).data
const out = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "running",
input: {
description: "investigate",
},
},
},
},
})
expect(out.status).toBeUndefined()
expect(out.commits).toEqual([])
})
test("tool error emits one commit", () => {
let data = createSessionData()
const evt = {
type: "message.part.updated",
properties: {
part: {
id: "tool-err",
sessionID: "session-1",
type: "tool",
tool: "bash",
state: {
status: "error",
input: {
command: "ls",
},
error: "boom",
time: { start: 1, end: 2 },
},
},
},
}
const first = reduce(data, evt)
expect(first.commits).toEqual([{ kind: "error", text: "bash: boom" }])
data = first.data
const next = reduce(data, evt)
expect(next.commits).toEqual([])
})
test("reasoning commits as reasoning kind", () => {
const out = reduce(
createSessionData(),
{
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
sessionID: "session-1",
type: "reasoning",
text: "step",
time: { start: 1, end: 2 },
},
},
},
true,
)
expect(out.commits).toEqual([{ kind: "reasoning", text: "step" }])
})
test("thinking disabled clears finalized reasoning delta", () => {
let data = createSessionData()
data = reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "msg-1",
partID: "reason-1",
field: "text",
delta: "hidden",
},
}).data
expect(data.delta.size).toBe(1)
const out = reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
sessionID: "session-1",
type: "reasoning",
text: "",
time: { start: 1, end: 2 },
},
},
})
expect(out.commits).toEqual([])
expect(out.data.delta.size).toBe(0)
})
test("permission asked updates status only", () => {
const out = reduce(createSessionData(), {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
metadata: {},
always: [],
},
})
expect(out.commits).toEqual([])
expect(out.status).toBe("permission requested: read (/tmp/file.txt); auto-rejecting")
})
test("other-session events are ignored", () => {
const data = createSessionData()
const out = reduce(data, {
type: "message.updated",
properties: {
sessionID: "other",
info: {
role: "assistant",
agent: "agent",
modelID: "model",
providerID: "provider",
tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 0, write: 0 } },
cost: 0,
},
},
})
expect(out.commits).toEqual([])
expect(out.status).toBeUndefined()
expect(out.usage).toBeUndefined()
expect(out.data.announced).toBe(false)
})
})

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