This commit is contained in:
Simon Klee
2026-03-28 16:19:24 +01:00
parent 44f83015cd
commit 685e237c4c
8 changed files with 1430 additions and 24 deletions

View File

@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { runDirectMode } 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 {
@@ -302,12 +310,38 @@ 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) => {
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 +354,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 +378,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)
}
@@ -389,7 +423,10 @@ export const RunCommand = cmd({
if (baseID) return baseID
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
const result = await sdk.session.create({
title: name,
permission: rules,
})
return result.data?.id
}
@@ -432,21 +469,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
@@ -626,21 +669,25 @@ export const RunCommand = cmd({
}
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 +696,21 @@ export const RunCommand = cmd({
variant: args.variant,
parts: [...files, { type: "text", text: message }],
})
return
}
const model = args.model ? Provider.parseModel(args.model) : undefined
await runDirectMode({
sdk,
sessionID,
agent,
model,
variant: args.variant,
files,
initialInput: message.trim().length > 0 ? message : undefined,
thinking: args.thinking,
})
return
}
if (args.attach) {
@@ -660,7 +721,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 +734,10 @@ export const RunCommand = cmd({
const request = new Request(input, init)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
})
await execute(sdk)
})
},

View File

@@ -0,0 +1,447 @@
import {
BoxRenderable,
CliRenderEvents,
TextRenderable,
TextareaRenderable,
type CliRenderer,
type KeyBinding,
type KeyEvent,
type ScrollbackWriter,
} from "@opentui/core"
import { directEntryWriter } from "./scrollback"
import type { DirectEntryKind } from "./types"
const HIGHLIGHT_COLOR = "#38bdf8"
const MUTED_COLOR = "#64748b"
const TEXT_COLOR = "#f8fafc"
const BORDER_COLOR = "#334155"
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
function directTextareaKeybindings(): KeyBinding[] {
return [
{ name: "return", action: "submit" },
{ name: "linefeed", action: "submit" },
{ name: "return", shift: true, action: "newline" },
{ name: "linefeed", shift: true, action: "newline" },
{ name: "return", ctrl: true, action: "newline" },
{ name: "linefeed", ctrl: true, action: "newline" },
{ name: "return", meta: true, action: "newline" },
{ name: "linefeed", meta: true, action: "newline" },
{ name: "j", ctrl: true, action: "newline" },
]
}
function isExitCommand(input: string): boolean {
const normalized = input.trim().toLowerCase()
return normalized === "/exit" || normalized === "/quit"
}
export type ScrollbackRenderer = CliRenderer & {
writeToScrollback: (write: ScrollbackWriter) => void
}
type FooterHistoryState = {
items: string[]
index: number | null
draft: string
}
export class DirectRunFooter {
private shell: BoxRenderable
private composerFrame: BoxRenderable
private composerArea: BoxRenderable
private composer: TextareaRenderable
private metaRow: BoxRenderable
private agentText: TextRenderable
private modelText: TextRenderable
private separatorRow: BoxRenderable
private separatorLine: BoxRenderable
private footerRow: BoxRenderable
private statusText: TextRenderable
private hintText: TextRenderable
private pendingInput: ((value: string | null) => void) | null = null
private closed = false
private busy = false
private destroyed = false
private statusMessage = "ready"
private history: FooterHistoryState = {
items: [],
index: null,
draft: "",
}
constructor(
private renderer: ScrollbackRenderer,
info: {
agentLabel: string
modelLabel: string
},
) {
this.shell = new BoxRenderable(renderer, {
id: "run-direct-footer-shell",
width: "100%",
height: "100%",
border: false,
backgroundColor: "transparent",
padding: 0,
gap: 0,
flexDirection: "column",
})
this.composerFrame = new BoxRenderable(renderer, {
id: "run-direct-footer-composer-frame",
width: "100%",
flexShrink: 0,
border: ["left"],
borderColor: HIGHLIGHT_COLOR,
customBorderChars: {
...EMPTY_BORDER,
vertical: "┃",
bottomLeft: "╹",
},
})
this.composerArea = new BoxRenderable(renderer, {
id: "run-direct-footer-composer-area",
width: "100%",
flexGrow: 1,
paddingLeft: 2,
paddingRight: 2,
paddingTop: 1,
gap: 0,
flexDirection: "column",
backgroundColor: "transparent",
})
this.composer = new TextareaRenderable(renderer, {
id: "run-direct-footer-composer",
width: "100%",
minHeight: 1,
maxHeight: 6,
wrapMode: "word",
showCursor: true,
placeholder: 'Ask anything... "Fix a TODO in the codebase"',
placeholderColor: MUTED_COLOR,
textColor: TEXT_COLOR,
focusedTextColor: TEXT_COLOR,
backgroundColor: "transparent",
focusedBackgroundColor: "transparent",
cursorColor: TEXT_COLOR,
onSubmit: this.handleSubmit,
onKeyDown: this.handleComposerKeyDown,
keyBindings: directTextareaKeybindings(),
})
this.metaRow = new BoxRenderable(renderer, {
id: "run-direct-footer-meta-row",
width: "100%",
flexDirection: "row",
gap: 1,
paddingTop: 1,
flexShrink: 0,
})
this.agentText = new TextRenderable(renderer, {
id: "run-direct-footer-agent",
content: info.agentLabel,
fg: HIGHLIGHT_COLOR,
})
this.modelText = new TextRenderable(renderer, {
id: "run-direct-footer-model",
content: info.modelLabel,
fg: MUTED_COLOR,
})
this.separatorRow = new BoxRenderable(renderer, {
id: "run-direct-footer-separator-row",
width: "100%",
height: 1,
border: ["left"],
borderColor: HIGHLIGHT_COLOR,
customBorderChars: {
...EMPTY_BORDER,
vertical: "╹",
},
})
this.separatorLine = new BoxRenderable(renderer, {
id: "run-direct-footer-separator-line",
width: "100%",
height: 1,
border: ["bottom"],
borderColor: BORDER_COLOR,
customBorderChars: {
...EMPTY_BORDER,
horizontal: "─",
},
})
this.footerRow = new BoxRenderable(renderer, {
id: "run-direct-footer-row",
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
gap: 1,
flexShrink: 0,
})
this.statusText = new TextRenderable(renderer, {
id: "run-direct-footer-status",
content: "",
fg: TEXT_COLOR,
})
this.hintText = new TextRenderable(renderer, {
id: "run-direct-footer-hint",
content: "",
fg: MUTED_COLOR,
})
this.metaRow.add(this.agentText)
this.metaRow.add(this.modelText)
this.composerArea.add(this.composer)
this.composerArea.add(this.metaRow)
this.composerFrame.add(this.composerArea)
this.separatorRow.add(this.separatorLine)
this.footerRow.add(this.statusText)
this.footerRow.add(this.hintText)
this.shell.add(this.composerFrame)
this.shell.add(this.separatorRow)
this.shell.add(this.footerRow)
this.renderer.root.add(this.shell)
this.composer.on("line-info-change", this.handleDraftChanged)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
this.refreshFooterRow()
this.composer.focus()
}
public get isClosed(): boolean {
return this.closed || this.destroyed || this.renderer.isDestroyed
}
public setStatus(status: string): void {
this.statusMessage = status
this.refreshFooterRow()
}
public setBusy(status: string): void {
this.busy = true
this.setStatus(status)
}
public setIdle(status: string = "ready"): void {
this.busy = false
this.setStatus(status)
this.composer.focus()
}
public append(kind: DirectEntryKind, text: string): void {
if (this.destroyed || this.renderer.isDestroyed) return
if (text.trim().length === 0) return
this.renderer.writeToScrollback(directEntryWriter(kind, text, new Date()))
}
public waitForInput(): Promise<string | null> {
if (this.isClosed) {
return Promise.resolve(null)
}
this.setIdle("ready")
return new Promise((resolve) => {
this.pendingInput = resolve
this.composer.focus()
})
}
public close(): void {
if (this.closed) return
this.closed = true
const pending = this.pendingInput
this.pendingInput = null
pending?.(null)
if (!this.renderer.isDestroyed) {
this.renderer.destroy()
}
}
public destroy(): void {
if (this.destroyed) return
this.destroyed = true
this.closed = true
const pending = this.pendingInput
this.pendingInput = null
pending?.(null)
this.composer.off("line-info-change", this.handleDraftChanged)
this.composer.onSubmit = undefined
this.composer.onKeyDown = undefined
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
if (!this.renderer.isDestroyed) {
this.renderer.root.remove(this.shell.id)
}
}
private refreshFooterRow(): void {
if (this.destroyed || this.renderer.isDestroyed) {
return
}
const draftLength = this.composer.isDestroyed ? 0 : this.composer.plainText.length
const busyLabel = this.busy ? "busy" : "idle"
this.statusText.content = `run -i · ${this.statusMessage} · ${busyLabel} · draft ${draftLength}`
this.hintText.content = this.busy
? "waiting for response · /exit quit"
: "Enter send · Shift/Ctrl/Alt+Enter newline · Up/Down history · /exit quit"
}
private pushHistory(input: string): void {
if (!input) {
return
}
if (this.history.items[this.history.items.length - 1] === input) {
this.history.index = null
this.history.draft = ""
return
}
this.history.items.push(input)
if (this.history.items.length > 200) {
this.history.items.shift()
}
this.history.index = null
this.history.draft = ""
}
private moveHistory(direction: -1 | 1, event: KeyEvent): void {
if (this.history.items.length === 0) {
return
}
if (direction === -1 && this.composer.cursorOffset !== 0) {
return
}
if (direction === 1 && this.composer.cursorOffset !== this.composer.plainText.length) {
return
}
if (this.history.index === null) {
if (direction === 1) {
return
}
this.history.draft = this.composer.plainText
this.history.index = this.history.items.length - 1
} else {
const nextIndex = this.history.index + direction
if (nextIndex < 0) {
return
}
if (nextIndex >= this.history.items.length) {
this.history.index = null
this.composer.setText(this.history.draft)
this.composer.cursorOffset = this.composer.plainText.length
event.preventDefault()
this.refreshFooterRow()
return
}
this.history.index = nextIndex
}
const next = this.history.items[this.history.index]
this.composer.setText(next)
this.composer.cursorOffset = direction === -1 ? 0 : this.composer.plainText.length
event.preventDefault()
this.refreshFooterRow()
}
private handleComposerKeyDown = (event: KeyEvent): void => {
if (this.destroyed || this.renderer.isDestroyed) {
return
}
if (event.ctrl || event.meta || event.shift || event.super || event.hyper) {
return
}
if (event.name === "up") {
this.moveHistory(-1, event)
return
}
if (event.name === "down") {
this.moveHistory(1, event)
}
}
private handleSubmit = (): void => {
if (this.destroyed || this.renderer.isDestroyed) return
const input = this.composer.plainText.trim()
if (!input) {
this.setStatus(this.busy ? "waiting for current response" : "empty prompt ignored")
return
}
if (isExitCommand(input)) {
this.close()
return
}
if (this.busy) {
this.setStatus("waiting for current response")
return
}
if (!this.pendingInput) {
this.setStatus("input queue unavailable")
return
}
this.pushHistory(input)
this.composer.setText("")
this.composer.focus()
const pending = this.pendingInput
this.pendingInput = null
pending(input)
}
private handleDraftChanged = (): void => {
this.refreshFooterRow()
}
private handleDestroy = (): void => {
this.closed = true
const pending = this.pendingInput
this.pendingInput = null
pending?.(null)
}
}

View File

@@ -0,0 +1,126 @@
import { CliRenderer, createCliRenderer, type ScrollbackWriter } from "@opentui/core"
import { DirectRunFooter, type ScrollbackRenderer } from "./footer"
import { formatUnknownError, runDirectPromptTurn } from "./stream"
import type { DirectRunInput } from "./types"
const DIRECT_FOOTER_HEIGHT = 9
function ensureScrollbackApiAvailable(): void {
const prototype = CliRenderer.prototype as CliRenderer & {
writeToScrollback?: unknown
}
if (typeof prototype.writeToScrollback === "function") {
return
}
throw new Error(
'run --interactive requires @opentui/core with writeToScrollback(). Link your local cli-render-api worktree (e.g. "bun link @opentui/core") before running this mode.',
)
}
function resolveScrollbackRenderer(renderer: CliRenderer): ScrollbackRenderer {
const candidate = renderer as CliRenderer & {
writeToScrollback?: (write: ScrollbackWriter) => void
}
if (typeof candidate.writeToScrollback === "function") {
return candidate as ScrollbackRenderer
}
if (!renderer.isDestroyed) {
renderer.destroy()
}
throw new Error(
'run --interactive requires @opentui/core with writeToScrollback(). Link your local cli-render-api worktree (e.g. "bun link @opentui/core") before running this mode.',
)
}
function directFooterLabels(input: Pick<DirectRunInput, "agent" | "model" | "variant">): {
agentLabel: string
modelLabel: string
} {
const agentLabel = `Agent ${input.agent ?? "default"}`
if (!input.model) {
return {
agentLabel,
modelLabel: "Model default",
}
}
const variantLabel = input.variant ? ` · ${input.variant}` : ""
return {
agentLabel,
modelLabel: `${input.model.modelID} · ${input.model.providerID}${variantLabel}`,
}
}
export async function runDirectMode(input: DirectRunInput): Promise<void> {
ensureScrollbackApiAvailable()
const renderer = resolveScrollbackRenderer(
await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: true,
useKittyKeyboard: { events: process.platform === "win32" },
screenMode: "split-footer",
footerHeight: DIRECT_FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
}),
)
const footer = new DirectRunFooter(renderer, directFooterLabels(input))
renderer.start()
try {
footer.append("system", "Interactive direct mode enabled. Type /exit or /quit to finish.")
let includeFiles = true
let prompt: string | null | undefined = input.initialInput?.trim() ? input.initialInput : undefined
while (!footer.isClosed) {
if (!prompt) {
prompt = await footer.waitForInput()
if (!prompt) {
break
}
}
footer.append("user", prompt)
footer.setBusy("sending prompt")
try {
await runDirectPromptTurn({
sdk: input.sdk,
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
variant: input.variant,
prompt,
files: input.files,
includeFiles,
thinking: input.thinking,
footer,
})
includeFiles = false
} catch (error) {
footer.append("error", formatUnknownError(error))
}
prompt = undefined
footer.setIdle("ready")
}
} finally {
footer.destroy()
if (!renderer.isDestroyed) {
renderer.destroy()
}
}
}

View File

@@ -0,0 +1,216 @@
import {
BoxRenderable,
TextRenderable,
type ScrollbackRenderContext,
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import type { DirectEntryKind } from "./types"
type DirectEntryStyle = {
label: string
border: string
heading: string
body: string
}
const MAX_ENTRY_WIDTH = 92
const DIRECT_ENTRY_STYLES: Record<DirectEntryKind, DirectEntryStyle> = {
system: {
label: "SYSTEM",
border: "#64748b",
heading: "#94a3b8",
body: "#cbd5e1",
},
user: {
label: "YOU",
border: "#38bdf8",
heading: "#7dd3fc",
body: "#e0f2fe",
},
assistant: {
label: "ASSISTANT",
border: "#22d3ee",
heading: "#67e8f9",
body: "#f8fafc",
},
tool: {
label: "TOOL",
border: "#f59e0b",
heading: "#fcd34d",
body: "#fef3c7",
},
error: {
label: "ERROR",
border: "#ef4444",
heading: "#fca5a5",
body: "#fee2e2",
},
}
let snapshotNodeCounter = 0
function lineColumns(line: string): number {
return [...line].length
}
function splitToken(token: string, width: number): string[] {
const clampedWidth = Math.max(1, width)
const chunks: string[] = []
for (let offset = 0; offset < token.length; offset += clampedWidth) {
chunks.push(token.slice(offset, offset + clampedWidth))
}
return chunks
}
function wrapText(text: string, width: number): string[] {
const clampedWidth = Math.max(1, width)
const paragraphs = text.replace(/\r/g, "").split("\n")
const wrapped: string[] = []
for (const paragraph of paragraphs) {
if (paragraph.length === 0) {
wrapped.push("")
continue
}
const words = paragraph.split(/\s+/)
let current = ""
for (const word of words) {
if (!word) {
continue
}
if (!current) {
if (word.length <= clampedWidth) {
current = word
} else {
const segments = splitToken(word, clampedWidth)
current = segments.pop() ?? ""
wrapped.push(...segments)
}
continue
}
const candidate = `${current} ${word}`
if (candidate.length <= clampedWidth) {
current = candidate
continue
}
wrapped.push(current)
if (word.length <= clampedWidth) {
current = word
} else {
const segments = splitToken(word, clampedWidth)
current = segments.pop() ?? ""
wrapped.push(...segments)
}
}
wrapped.push(current)
}
return wrapped.length > 0 ? wrapped : [""]
}
function truncateText(text: string, width: number): string {
if (width <= 0) {
return ""
}
const chars = [...text]
if (chars.length <= width) {
return text
}
if (width <= 3) {
return chars.slice(0, width).join("")
}
return `${chars.slice(0, width - 3).join("")}...`
}
function formatTimestamp(timestamp: Date): string {
const hh = timestamp.getHours().toString().padStart(2, "0")
const mm = timestamp.getMinutes().toString().padStart(2, "0")
const ss = timestamp.getSeconds().toString().padStart(2, "0")
return `${hh}:${mm}:${ss}`
}
function buildSnapshot(
kind: DirectEntryKind,
text: string,
timestamp: Date,
context: ScrollbackRenderContext,
): ScrollbackSnapshot {
const style = DIRECT_ENTRY_STYLES[kind]
const width = Math.max(3, context.width)
const maxTextWidth = Math.max(18, Math.min(width - 3, MAX_ENTRY_WIDTH))
const headingCore = truncateText(`${style.label} | ${formatTimestamp(timestamp)}`, maxTextWidth - 1)
const headingLine = ` ${headingCore}`
const bodyLines = wrapText(text, Math.max(1, maxTextWidth - 1)).map((line) => ` ${line}`)
const longestBody = bodyLines.reduce((maxWidth, line) => Math.max(maxWidth, lineColumns(line)), 1)
const longestLine = Math.max(lineColumns(headingLine), longestBody)
const textWidth = Math.min(maxTextWidth, Math.max(2, longestLine + 1))
const boxWidth = Math.min(width, Math.max(3, textWidth + 1))
const boxHeight = Math.max(3, bodyLines.length + 1)
const box = new BoxRenderable(context.renderContext, {
id: `run-direct-box-${snapshotNodeCounter++}`,
position: "absolute",
left: 0,
top: 0,
width: boxWidth,
height: boxHeight,
border: ["left"],
borderStyle: "single",
borderColor: style.border,
backgroundColor: "transparent",
})
const headingText = new TextRenderable(context.renderContext, {
id: `run-direct-heading-${snapshotNodeCounter++}`,
position: "absolute",
left: 1,
top: 0,
width: Math.max(1, boxWidth - 1),
height: 1,
content: headingLine,
fg: style.heading,
attributes: 1,
})
const bodyText = new TextRenderable(context.renderContext, {
id: `run-direct-body-${snapshotNodeCounter++}`,
position: "absolute",
left: 1,
top: 1,
width: Math.max(1, boxWidth - 1),
height: Math.max(1, boxHeight - 1),
content: bodyLines.join("\n"),
fg: style.body,
})
box.add(headingText)
box.add(bodyText)
return {
root: box,
width: boxWidth,
height: boxHeight,
rowColumns: boxWidth,
startOnNewLine: true,
trailingNewline: true,
}
}
export function directEntryWriter(kind: DirectEntryKind, text: string, timestamp: Date = new Date()): ScrollbackWriter {
return (context) => buildSnapshot(kind, text.replace(/\r/g, ""), timestamp, context)
}

View File

@@ -0,0 +1,270 @@
import path from "path"
import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
import type { DirectRunFilePart, DirectRunInput } from "./types"
type DirectRunView = {
readonly isClosed: boolean
append: (kind: "system" | "user" | "assistant" | "tool" | "error", text: string) => void
setBusy: (status: string) => void
}
type DirectTurnInput = {
sdk: OpencodeClient
sessionID: string
agent: string | undefined
model: DirectRunInput["model"]
variant: string | undefined
prompt: string
files: DirectRunFilePart[]
includeFiles: boolean
thinking: boolean
footer: DirectRunView
}
function normalizePath(input?: string): string {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
}
function formatToolTitle(part: ToolPart): string {
const state = part.state as {
input?: Record<string, unknown>
title?: string
}
const input = state.input
if (part.tool === "bash" && typeof input?.command === "string") {
return `$ ${input.command}`
}
if ((part.tool === "read" || part.tool === "write" || part.tool === "edit") && typeof input?.filePath === "string") {
return `${part.tool} ${normalizePath(input.filePath)}`
}
if (part.tool === "glob" && typeof input?.pattern === "string") {
return `glob ${input.pattern}`
}
if (part.tool === "grep" && typeof input?.pattern === "string") {
return `grep ${input.pattern}`
}
if (part.tool === "webfetch" && typeof input?.url === "string") {
return `webfetch ${input.url}`
}
if (part.tool === "skill" && typeof input?.name === "string") {
return `skill ${input.name}`
}
if (part.tool === "task") {
if (typeof input?.description === "string" && input.description.trim()) {
return `task ${input.description}`
}
if (typeof input?.subagent_type === "string" && input.subagent_type.trim()) {
return `task ${input.subagent_type}`
}
}
if (typeof state.title === "string" && state.title.trim()) {
return `${part.tool} ${state.title}`
}
if (input && typeof input === "object" && Object.keys(input).length > 0) {
return `${part.tool} ${JSON.stringify(input)}`
}
return part.tool
}
function formatToolOutput(part: ToolPart): string | undefined {
const state = part.state as {
output?: unknown
input?: {
todos?: {
content: string
status: string
}[]
}
}
if (typeof state.output === "string" && state.output.trim().length > 0) {
return state.output.trimEnd()
}
if (part.tool !== "todowrite") {
return
}
const todos = state.input?.todos
if (!Array.isArray(todos) || todos.length === 0) {
return
}
return todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n")
}
function formatSessionError(error: {
name: string
data?: {
message?: string
}
}): string {
if (error.data?.message) {
return String(error.data.message)
}
return String(error.name)
}
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 runDirectPromptTurn(input: DirectTurnInput): Promise<void> {
const abort = new AbortController()
const events = await input.sdk.event.subscribe(undefined, {
signal: abort.signal,
})
const seen = new Set<string>()
const runningTasks = new Set<string>()
let announcedAssistant = false
const watch = (async () => {
try {
for await (const event of events.stream) {
if (input.footer.isClosed) {
break
}
if (
event.type === "message.updated" &&
event.properties.sessionID === input.sessionID &&
event.properties.info.role === "assistant" &&
!announcedAssistant
) {
input.footer.append("system", `${event.properties.info.agent} · ${event.properties.info.modelID}`)
input.footer.setBusy("assistant responding")
announcedAssistant = true
}
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== input.sessionID) continue
if (
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
runningTasks.has(part.id) === false
) {
runningTasks.add(part.id)
const state = part.state as {
input?: { description?: string; subagent_type?: string }
}
const description = state.input?.description?.trim() || state.input?.subagent_type?.trim() || "task"
input.footer.append("tool", `running ${description}`)
}
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (seen.has(part.id)) continue
seen.add(part.id)
if (part.state.status === "error") {
input.footer.append("error", `${part.tool} failed\n${part.state.error}`)
continue
}
const title = formatToolTitle(part)
const output = formatToolOutput(part)
input.footer.append("tool", output ? `${title}\n${output}` : title)
continue
}
if (part.type === "text" && part.time?.end) {
if (seen.has(part.id)) continue
seen.add(part.id)
const text = part.text.trim()
if (!text) continue
input.footer.append("assistant", text)
continue
}
if (part.type === "reasoning" && part.time?.end && input.thinking) {
if (seen.has(part.id)) continue
seen.add(part.id)
const text = part.text.trim()
if (!text) continue
input.footer.append("system", `Thinking: ${text}`)
continue
}
}
if (event.type === "session.error") {
if (event.properties.sessionID !== input.sessionID || !event.properties.error) continue
input.footer.append("error", formatSessionError(event.properties.error))
}
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
input.footer.append(
"system",
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
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 }],
})
await watch
} catch (error) {
abort.abort()
await watch.catch(() => {})
throw error
} finally {
abort.abort()
}
}

View File

@@ -0,0 +1,23 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
export type DirectRunFilePart = {
type: "file"
url: string
filename: string
mime: string
}
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
export type DirectRunInput = {
sdk: OpencodeClient
sessionID: string
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
files: DirectRunFilePart[]
initialInput?: string
thinking: boolean
}
export type DirectEntryKind = "system" | "user" | "assistant" | "tool" | "error"

View File

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

View File

@@ -0,0 +1,255 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runDirectPromptTurn } from "../../../src/cli/cmd/run/stream"
function eventStream(events: unknown[]) {
return {
stream: (async function* () {
for (const event of events) {
yield event
}
})(),
}
}
describe("run direct stream", () => {
test("keeps event order and ignores other sessions", async () => {
const appended: Array<{ kind: string; text: string }> = []
const busy: string[] = []
const promptCalls: unknown[] = []
const sdk = {
event: {
subscribe: async () =>
eventStream([
{
type: "message.updated",
properties: {
sessionID: "other",
info: {
role: "assistant",
agent: "other-agent",
modelID: "other-model",
},
},
},
{
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
role: "assistant",
agent: "main-agent",
modelID: "main-model",
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "assistant reply",
time: { end: Date.now() },
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
sessionID: "session-1",
type: "text",
text: "assistant reply",
time: { end: Date.now() },
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "task-1",
sessionID: "session-1",
type: "tool",
tool: "task",
state: {
status: "running",
input: {
description: "investigate",
},
},
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "session-1",
type: "tool",
tool: "bash",
state: {
status: "completed",
input: {
command: "ls",
},
output: "file-a\n",
},
},
},
},
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "idle",
},
},
},
]),
},
session: {
prompt: async (payload: unknown) => {
promptCalls.push(payload)
},
},
permission: {
reply: async () => {},
},
} as unknown as OpencodeClient
await runDirectPromptTurn({
sdk,
sessionID: "session-1",
agent: "agent",
model: undefined,
variant: undefined,
prompt: "hello",
files: [
{
type: "file",
url: "file:///tmp/a.txt",
filename: "a.txt",
mime: "text/plain",
},
],
includeFiles: true,
thinking: false,
footer: {
isClosed: false,
append(kind, text) {
appended.push({ kind, text })
},
setBusy(status) {
busy.push(status)
},
},
})
expect(promptCalls).toHaveLength(1)
expect((promptCalls[0] as { parts: unknown[] }).parts).toHaveLength(2)
expect((promptCalls[0] as { parts: Array<{ type: string }> }).parts[0]?.type).toBe("file")
expect(busy).toEqual(["assistant responding"])
expect(appended).toEqual([
{ kind: "system", text: "main-agent · main-model" },
{ kind: "assistant", text: "assistant reply" },
{ kind: "tool", text: "running investigate" },
{ kind: "tool", text: "$ ls\nfile-a" },
])
})
test("auto rejects permissions and emits session errors", async () => {
const appended: Array<{ kind: string; text: string }> = []
const permissionReplies: unknown[] = []
const sdk = {
event: {
subscribe: async () =>
eventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
},
},
{
type: "session.error",
properties: {
sessionID: "session-1",
error: {
name: "UnknownError",
data: {
message: "permission denied",
},
},
},
},
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "idle",
},
},
},
]),
},
session: {
prompt: async () => {},
},
permission: {
reply: async (payload: unknown) => {
permissionReplies.push(payload)
},
},
} as unknown as OpencodeClient
await runDirectPromptTurn({
sdk,
sessionID: "session-1",
agent: undefined,
model: undefined,
variant: undefined,
prompt: "hello",
files: [],
includeFiles: false,
thinking: false,
footer: {
isClosed: false,
append(kind, text) {
appended.push({ kind, text })
},
setBusy() {},
},
})
expect(permissionReplies).toEqual([
{
requestID: "perm-1",
reply: "reject",
},
])
expect(appended).toEqual([
{
kind: "system",
text: "permission requested: read (/tmp/file.txt); auto-rejecting",
},
{
kind: "error",
text: "permission denied",
},
])
})
})