mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-02 12:04:36 +00:00
Compare commits
22 Commits
production
...
oc-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb85552738 | ||
|
|
dbf6690e9b | ||
|
|
48e30e7f26 | ||
|
|
4e45169eec | ||
|
|
1e00672517 | ||
|
|
9c761ff619 | ||
|
|
ba82c11091 | ||
|
|
d179a5eeb3 | ||
|
|
3d6324459e | ||
|
|
d92cf629f6 | ||
|
|
857c0aa258 | ||
|
|
93acb5411f | ||
|
|
809e46c988 | ||
|
|
56c9f68368 | ||
|
|
4dad8d4bcb | ||
|
|
02a958b30c | ||
|
|
7871920b56 | ||
|
|
82075fa920 | ||
|
|
a3d3bf9a71 | ||
|
|
3146c216ec | ||
|
|
df84677212 | ||
|
|
685e237c4c |
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
387
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
387
packages/opencode/src/cli/cmd/run/footer.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
625
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
625
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1017
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
1017
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
171
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal file
171
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal 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)
|
||||
}
|
||||
395
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
395
packages/opencode/src/cli/cmd/run/session-data.ts
Normal 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)
|
||||
}
|
||||
251
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
251
packages/opencode/src/cli/cmd/run/splash.ts
Normal 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)
|
||||
}
|
||||
179
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
179
packages/opencode/src/cli/cmd/run/stream.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
144
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
144
packages/opencode/src/cli/cmd/run/theme.ts
Normal 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
|
||||
}
|
||||
}
|
||||
74
packages/opencode/src/cli/cmd/run/types.ts
Normal file
74
packages/opencode/src/cli/cmd/run/types.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,3 +4,44 @@ export const logo = {
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
|
||||
export type LogoCell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
export function logoCells(line: string): LogoCell[] {
|
||||
const cells: LogoCell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
cells.push({
|
||||
char: " ",
|
||||
mark: "full",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "mix",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "top",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cells.push({
|
||||
char,
|
||||
mark: "text",
|
||||
})
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { RunFooter } from "../../../src/cli/cmd/run/footer"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function create() {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
setup.renderer.screenMode = "split-footer"
|
||||
setup.renderer.footerHeight = 6
|
||||
|
||||
let interrupts = 0
|
||||
let exits = 0
|
||||
|
||||
const footer = new RunFooter(setup.renderer as any, {
|
||||
agentLabel: "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()
|
||||
}
|
||||
})
|
||||
})
|
||||
452
packages/opencode/test/cli/run/direct-runtime.test.ts
Normal file
452
packages/opencode/test/cli/run/direct-runtime.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
1119
packages/opencode/test/cli/run/direct-stream.test.ts
Normal file
1119
packages/opencode/test/cli/run/direct-stream.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
662
packages/opencode/test/cli/run/footer-view.test.tsx
Normal file
662
packages/opencode/test/cli/run/footer-view.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
194
packages/opencode/test/cli/run/scrollback.test.ts
Normal file
194
packages/opencode/test/cli/run/scrollback.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
393
packages/opencode/test/cli/run/session-data.test.ts
Normal file
393
packages/opencode/test/cli/run/session-data.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import {
|
||||
SPLASH_TITLE_FALLBACK,
|
||||
SPLASH_TITLE_LIMIT,
|
||||
entrySplash,
|
||||
exitSplash,
|
||||
splashMeta,
|
||||
} from "../../../src/cli/cmd/run/splash"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function draw(write: ReturnType<typeof entrySplash>) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 24,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = write({
|
||||
width: 100,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
const children = root.getChildren() as any[]
|
||||
const rows = new Map<number, string[]>()
|
||||
|
||||
for (const child of children) {
|
||||
if (typeof child.left !== "number" || typeof child.top !== "number") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof child.plainText !== "string" || child.plainText.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = rows.get(child.top) ?? []
|
||||
for (let i = 0; i < child.plainText.length; i += 1) {
|
||||
row[child.left + i] = child.plainText[i]
|
||||
}
|
||||
rows.set(child.top, row)
|
||||
}
|
||||
|
||||
const lines = [...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row.join("").replace(/\s+$/g, ""))
|
||||
|
||||
return {
|
||||
snap,
|
||||
lines,
|
||||
children,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
describe("run splash", () => {
|
||||
test("builds entry text with logo", () => {
|
||||
const text = entrySplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.some((line) => line.includes("█▀▀█"))).toBe(true)
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Type /exit or /quit to finish.")
|
||||
expect(out.children.some((item) => item.plainText === " " && item.bg && item.bg.a > 0)).toBe(true)
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("builds exit text with aligned rows", () => {
|
||||
const text = exitSplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Continue opencode -s sess-1")
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
test("applies stable fallback title", () => {
|
||||
expect(
|
||||
splashMeta({
|
||||
title: undefined,
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
|
||||
expect(
|
||||
splashMeta({
|
||||
title: " ",
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
})
|
||||
|
||||
test("truncates title with tui cap", () => {
|
||||
const meta = splashMeta({
|
||||
title: "x".repeat(80),
|
||||
session_id: "sess-1",
|
||||
})
|
||||
|
||||
expect(meta.title.length).toBeLessThanOrEqual(SPLASH_TITLE_LIMIT)
|
||||
expect(meta.title.endsWith("…")).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user