feat(cli-run): add streaming delta, think block rendering, and rich tool headers
Adds message.part.delta event handling for real-time streaming output, reasoning/think block display with in-place updates, per-agent profile colors, padded text output, and semantic tool headers with icons. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
+149
-33
@@ -7,14 +7,22 @@ import type {
|
||||
SessionErrorProps,
|
||||
MessageUpdatedProps,
|
||||
MessagePartUpdatedProps,
|
||||
MessagePartDeltaProps,
|
||||
ToolExecuteProps,
|
||||
ToolResultProps,
|
||||
TuiToastShowProps,
|
||||
} from "./types"
|
||||
import type { EventState } from "./event-state"
|
||||
import { serializeError } from "./event-formatting"
|
||||
import { formatToolInputPreview } from "./tool-input-preview"
|
||||
import { formatToolHeader } from "./tool-input-preview"
|
||||
import { displayChars } from "./display-chars"
|
||||
import {
|
||||
closeThinkBlock,
|
||||
openThinkBlock,
|
||||
renderAgentHeader,
|
||||
renderThinkingLine,
|
||||
writePaddedText,
|
||||
} from "./output-renderer"
|
||||
|
||||
function getSessionId(props?: { sessionID?: string; sessionId?: string }): string | undefined {
|
||||
return props?.sessionID ?? props?.sessionId
|
||||
@@ -32,6 +40,18 @@ function getPartSessionId(props?: {
|
||||
return props?.part?.sessionID ?? props?.part?.sessionId
|
||||
}
|
||||
|
||||
function getPartMessageId(props?: {
|
||||
part?: { messageID?: string }
|
||||
}): string | undefined {
|
||||
return props?.part?.messageID
|
||||
}
|
||||
|
||||
function getDeltaMessageId(props?: {
|
||||
messageID?: string
|
||||
}): string | undefined {
|
||||
return props?.messageID
|
||||
}
|
||||
|
||||
export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||
if (payload.type !== "session.idle") return
|
||||
|
||||
@@ -76,16 +96,36 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
|
||||
const infoSid = getInfoSessionId(props)
|
||||
if ((partSid ?? infoSid) !== ctx.sessionID) return
|
||||
|
||||
const role = props?.info?.role ?? state.currentMessageRole
|
||||
if (role === "user") return
|
||||
const role = props?.info?.role
|
||||
const mappedRole = getPartMessageId(props)
|
||||
? state.messageRoleById[getPartMessageId(props) ?? ""]
|
||||
: undefined
|
||||
if ((role ?? mappedRole) === "user") return
|
||||
|
||||
const part = props?.part
|
||||
if (!part) return
|
||||
|
||||
if (part.id && part.type) {
|
||||
state.partTypesById[part.id] = part.type
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
ensureThinkBlockOpen(state)
|
||||
const reasoningText = part.text ?? state.lastReasoningText
|
||||
maybePrintThinkingLine(state, reasoningText)
|
||||
state.lastReasoningText = reasoningText
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
return
|
||||
}
|
||||
|
||||
closeThinkBlockIfNeeded(state)
|
||||
|
||||
if (part.type === "text" && part.text) {
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
const padded = writePaddedText(newText, state.textAtLineStart)
|
||||
process.stdout.write(padded.output)
|
||||
state.textAtLineStart = padded.atLineStart
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
@@ -96,6 +136,43 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||
if (payload.type !== "message.part.delta") return
|
||||
|
||||
const props = payload.properties as MessagePartDeltaProps | undefined
|
||||
const sessionID = props?.sessionID ?? props?.sessionId
|
||||
if (sessionID !== ctx.sessionID) return
|
||||
|
||||
const role = getDeltaMessageId(props)
|
||||
? state.messageRoleById[getDeltaMessageId(props) ?? ""]
|
||||
: undefined
|
||||
if (role === "user") return
|
||||
|
||||
if (props?.field !== "text") return
|
||||
|
||||
const partType = props?.partID ? state.partTypesById[props.partID] : undefined
|
||||
|
||||
const delta = props.delta ?? ""
|
||||
if (!delta) return
|
||||
|
||||
if (partType === "reasoning") {
|
||||
ensureThinkBlockOpen(state)
|
||||
const nextReasoningText = `${state.lastReasoningText}${delta}`
|
||||
maybePrintThinkingLine(state, nextReasoningText)
|
||||
state.lastReasoningText = nextReasoningText
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
return
|
||||
}
|
||||
|
||||
closeThinkBlockIfNeeded(state)
|
||||
|
||||
const padded = writePaddedText(delta, state.textAtLineStart)
|
||||
process.stdout.write(padded.output)
|
||||
state.textAtLineStart = padded.atLineStart
|
||||
state.lastPartText += delta
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
|
||||
function handleToolPart(
|
||||
_ctx: RunContext,
|
||||
part: NonNullable<MessagePartUpdatedProps["part"]>,
|
||||
@@ -106,23 +183,23 @@ function handleToolPart(
|
||||
|
||||
if (status === "running") {
|
||||
state.currentTool = toolName
|
||||
const inputPreview = part.state?.input
|
||||
? formatToolInputPreview(part.state.input)
|
||||
: ""
|
||||
const header = formatToolHeader(toolName, part.state?.input ?? {})
|
||||
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
||||
}
|
||||
|
||||
if (status === "completed" || status === "error") {
|
||||
const output = part.state?.output || ""
|
||||
const maxLen = 200
|
||||
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
|
||||
if (preview.trim()) {
|
||||
const lines = preview.split("\n").slice(0, 3)
|
||||
process.stdout.write(pc.dim(`${displayChars.treeIndent}${displayChars.treeEnd} ${lines.join(`\n${displayChars.treeJoin}`)}\n`))
|
||||
if (output.trim()) {
|
||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
||||
const padded = writePaddedText(output, true)
|
||||
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
state.currentTool = null
|
||||
state.lastPartText = ""
|
||||
state.textAtLineStart = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,43 +210,50 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
|
||||
if (getInfoSessionId(props) !== ctx.sessionID) return
|
||||
|
||||
state.currentMessageRole = props?.info?.role ?? null
|
||||
|
||||
const messageID = props?.info?.id
|
||||
const role = props?.info?.role
|
||||
if (messageID && role) {
|
||||
state.messageRoleById[messageID] = role
|
||||
}
|
||||
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
state.messageCount++
|
||||
state.lastPartText = ""
|
||||
state.lastReasoningText = ""
|
||||
state.hasPrintedThinkingLine = false
|
||||
state.lastThinkingSummary = ""
|
||||
state.textAtLineStart = true
|
||||
closeThinkBlockIfNeeded(state)
|
||||
|
||||
const agent = props?.info?.agent ?? null
|
||||
const model = props?.info?.modelID ?? null
|
||||
if (agent !== state.currentAgent || model !== state.currentModel) {
|
||||
const variant = props?.info?.variant ?? null
|
||||
if (agent !== state.currentAgent || model !== state.currentModel || variant !== state.currentVariant) {
|
||||
state.currentAgent = agent
|
||||
state.currentModel = model
|
||||
printAgentHeader(agent, model)
|
||||
state.currentVariant = variant
|
||||
renderAgentHeader(agent, model, variant, state.agentColorsByName)
|
||||
}
|
||||
}
|
||||
|
||||
function printAgentHeader(agent: string | null, model: string | null): void {
|
||||
if (!agent && !model) return
|
||||
const agentLabel = agent ? pc.bold(pc.magenta(agent)) : ""
|
||||
const modelLabel = model ? pc.dim(model) : ""
|
||||
const separator = agent && model ? " " : ""
|
||||
process.stdout.write(`\n${agentLabel}${separator}${modelLabel}\n`)
|
||||
}
|
||||
|
||||
export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||
if (payload.type !== "tool.execute") return
|
||||
|
||||
const props = payload.properties as ToolExecuteProps | undefined
|
||||
if (getSessionId(props) !== ctx.sessionID) return
|
||||
|
||||
closeThinkBlockIfNeeded(state)
|
||||
|
||||
const toolName = props?.name || "unknown"
|
||||
state.currentTool = toolName
|
||||
const inputPreview = props?.input
|
||||
? formatToolInputPreview(props.input)
|
||||
: ""
|
||||
const header = formatToolHeader(toolName, props?.input ?? {})
|
||||
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
|
||||
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
|
||||
}
|
||||
|
||||
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||
@@ -178,17 +262,19 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state:
|
||||
const props = payload.properties as ToolResultProps | undefined
|
||||
if (getSessionId(props) !== ctx.sessionID) return
|
||||
|
||||
const output = props?.output || ""
|
||||
const maxLen = 200
|
||||
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
|
||||
closeThinkBlockIfNeeded(state)
|
||||
|
||||
if (preview.trim()) {
|
||||
const lines = preview.split("\n").slice(0, 3)
|
||||
process.stdout.write(pc.dim(`${displayChars.treeIndent}${displayChars.treeEnd} ${lines.join(`\n${displayChars.treeJoin}`)}\n`))
|
||||
const output = props?.output || ""
|
||||
if (output.trim()) {
|
||||
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
|
||||
const padded = writePaddedText(output, true)
|
||||
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
|
||||
state.currentTool = null
|
||||
state.lastPartText = ""
|
||||
state.textAtLineStart = true
|
||||
}
|
||||
|
||||
export function handleTuiToast(_ctx: RunContext, payload: EventPayload, state: EventState): void {
|
||||
@@ -206,3 +292,33 @@ export function handleTuiToast(_ctx: RunContext, payload: EventPayload, state: E
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureThinkBlockOpen(state: EventState): void {
|
||||
if (state.inThinkBlock) return
|
||||
openThinkBlock()
|
||||
state.inThinkBlock = true
|
||||
state.hasPrintedThinkingLine = false
|
||||
}
|
||||
|
||||
function closeThinkBlockIfNeeded(state: EventState): void {
|
||||
if (!state.inThinkBlock) return
|
||||
closeThinkBlock()
|
||||
state.inThinkBlock = false
|
||||
state.lastThinkingLineWidth = 0
|
||||
state.lastThinkingSummary = ""
|
||||
}
|
||||
|
||||
function maybePrintThinkingLine(state: EventState, text: string): void {
|
||||
const normalized = text.replace(/\s+/g, " ").trim()
|
||||
if (!normalized) return
|
||||
|
||||
const summary = normalized
|
||||
if (summary === state.lastThinkingSummary) return
|
||||
|
||||
state.lastThinkingLineWidth = renderThinkingLine(
|
||||
summary,
|
||||
state.lastThinkingLineWidth,
|
||||
)
|
||||
state.lastThinkingSummary = summary
|
||||
state.hasPrintedThinkingLine = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user