mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
wip
This commit is contained in:
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
447
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
447
packages/opencode/src/cli/cmd/run/footer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
126
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
126
packages/opencode/src/cli/cmd/run/runtime.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
216
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal file
216
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal 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)
|
||||
}
|
||||
270
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
270
packages/opencode/src/cli/cmd/run/stream.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
23
packages/opencode/src/cli/cmd/run/types.ts
Normal file
23
packages/opencode/src/cli/cmd/run/types.ts
Normal 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"
|
||||
@@ -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()
|
||||
|
||||
255
packages/opencode/test/cli/run/direct-stream.test.ts
Normal file
255
packages/opencode/test/cli/run/direct-stream.test.ts
Normal 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",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user