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:
YeonGyu-Kim 2026-02-17 23:33:06 +09:00
parent 4bb8fa4a7f
commit eaf315a8d7
10 changed files with 945 additions and 73 deletions

View File

@ -0,0 +1,28 @@
import type { OpencodeClient } from "@opencode-ai/sdk"
import { normalizeSDKResponse } from "../../shared"
interface AgentProfile {
name?: string
color?: string
}
export async function loadAgentProfileColors(
client: OpencodeClient,
): Promise<Record<string, string>> {
try {
const agentsRes = await client.app.agents()
const agents = normalizeSDKResponse(agentsRes, [] as AgentProfile[], {
preferResponseOnMissingData: true,
})
const colors: Record<string, string> = {}
for (const agent of agents) {
if (!agent.name || !agent.color) continue
colors[agent.name] = agent.color
}
return colors
} catch {
return {}
}
}

View File

@ -4,6 +4,7 @@ import type {
EventPayload, EventPayload,
MessageUpdatedProps, MessageUpdatedProps,
MessagePartUpdatedProps, MessagePartUpdatedProps,
MessagePartDeltaProps,
ToolExecuteProps, ToolExecuteProps,
ToolResultProps, ToolResultProps,
SessionErrorProps, SessionErrorProps,
@ -93,6 +94,15 @@ export function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
break break
} }
case "message.part.delta": {
const deltaProps = props as MessagePartDeltaProps | undefined
const field = deltaProps?.field ?? "unknown"
const delta = deltaProps?.delta ?? ""
const preview = delta.slice(0, 80).replace(/\n/g, "\\n")
console.error(pc.dim(`${sessionTag} message.part.delta (${field}): "${preview}${delta.length > 80 ? "..." : ""}"`))
break
}
case "message.updated": { case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown" const role = msgProps?.info?.role ?? "unknown"

View File

@ -7,14 +7,22 @@ import type {
SessionErrorProps, SessionErrorProps,
MessageUpdatedProps, MessageUpdatedProps,
MessagePartUpdatedProps, MessagePartUpdatedProps,
MessagePartDeltaProps,
ToolExecuteProps, ToolExecuteProps,
ToolResultProps, ToolResultProps,
TuiToastShowProps, TuiToastShowProps,
} from "./types" } from "./types"
import type { EventState } from "./event-state" import type { EventState } from "./event-state"
import { serializeError } from "./event-formatting" import { serializeError } from "./event-formatting"
import { formatToolInputPreview } from "./tool-input-preview" import { formatToolHeader } from "./tool-input-preview"
import { displayChars } from "./display-chars" import { displayChars } from "./display-chars"
import {
closeThinkBlock,
openThinkBlock,
renderAgentHeader,
renderThinkingLine,
writePaddedText,
} from "./output-renderer"
function getSessionId(props?: { sessionID?: string; sessionId?: string }): string | undefined { function getSessionId(props?: { sessionID?: string; sessionId?: string }): string | undefined {
return props?.sessionID ?? props?.sessionId return props?.sessionID ?? props?.sessionId
@ -32,6 +40,18 @@ function getPartSessionId(props?: {
return props?.part?.sessionID ?? props?.part?.sessionId 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 { export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.idle") return if (payload.type !== "session.idle") return
@ -76,16 +96,36 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
const infoSid = getInfoSessionId(props) const infoSid = getInfoSessionId(props)
if ((partSid ?? infoSid) !== ctx.sessionID) return if ((partSid ?? infoSid) !== ctx.sessionID) return
const role = props?.info?.role ?? state.currentMessageRole const role = props?.info?.role
if (role === "user") return const mappedRole = getPartMessageId(props)
? state.messageRoleById[getPartMessageId(props) ?? ""]
: undefined
if ((role ?? mappedRole) === "user") return
const part = props?.part const part = props?.part
if (!part) return 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) { if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length) const newText = part.text.slice(state.lastPartText.length)
if (newText) { 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.hasReceivedMeaningfulWork = true
} }
state.lastPartText = part.text 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( function handleToolPart(
_ctx: RunContext, _ctx: RunContext,
part: NonNullable<MessagePartUpdatedProps["part"]>, part: NonNullable<MessagePartUpdatedProps["part"]>,
@ -106,23 +183,23 @@ function handleToolPart(
if (status === "running") { if (status === "running") {
state.currentTool = toolName state.currentTool = toolName
const inputPreview = part.state?.input const header = formatToolHeader(toolName, part.state?.input ?? {})
? formatToolInputPreview(part.state.input) const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
: ""
state.hasReceivedMeaningfulWork = true 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") { if (status === "completed" || status === "error") {
const output = part.state?.output || "" const output = part.state?.output || ""
const maxLen = 200 if (output.trim()) {
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
if (preview.trim()) { const padded = writePaddedText(output, true)
const lines = preview.split("\n").slice(0, 3) process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
process.stdout.write(pc.dim(`${displayChars.treeIndent}${displayChars.treeEnd} ${lines.join(`\n${displayChars.treeJoin}`)}\n`)) process.stdout.write("\n")
} }
state.currentTool = null state.currentTool = null
state.lastPartText = "" state.lastPartText = ""
state.textAtLineStart = true
} }
} }
@ -133,43 +210,50 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
if (getInfoSessionId(props) !== ctx.sessionID) return if (getInfoSessionId(props) !== ctx.sessionID) return
state.currentMessageRole = props?.info?.role ?? null 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 if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true state.hasReceivedMeaningfulWork = true
state.messageCount++ state.messageCount++
state.lastPartText = "" state.lastPartText = ""
state.lastReasoningText = ""
state.hasPrintedThinkingLine = false
state.lastThinkingSummary = ""
state.textAtLineStart = true
closeThinkBlockIfNeeded(state)
const agent = props?.info?.agent ?? null const agent = props?.info?.agent ?? null
const model = props?.info?.modelID ?? 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.currentAgent = agent
state.currentModel = model 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 { export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tool.execute") return if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined const props = payload.properties as ToolExecuteProps | undefined
if (getSessionId(props) !== ctx.sessionID) return if (getSessionId(props) !== ctx.sessionID) return
closeThinkBlockIfNeeded(state)
const toolName = props?.name || "unknown" const toolName = props?.name || "unknown"
state.currentTool = toolName state.currentTool = toolName
const inputPreview = props?.input const header = formatToolHeader(toolName, props?.input ?? {})
? formatToolInputPreview(props.input) const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
: ""
state.hasReceivedMeaningfulWork = true 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 { 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 const props = payload.properties as ToolResultProps | undefined
if (getSessionId(props) !== ctx.sessionID) return if (getSessionId(props) !== ctx.sessionID) return
const output = props?.output || "" closeThinkBlockIfNeeded(state)
const maxLen = 200
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
if (preview.trim()) { const output = props?.output || ""
const lines = preview.split("\n").slice(0, 3) if (output.trim()) {
process.stdout.write(pc.dim(`${displayChars.treeIndent}${displayChars.treeEnd} ${lines.join(`\n${displayChars.treeJoin}`)}\n`)) 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.currentTool = null
state.lastPartText = "" state.lastPartText = ""
state.textAtLineStart = true
} }
export function handleTuiToast(_ctx: RunContext, payload: EventPayload, state: EventState): void { 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
}

View File

@ -13,8 +13,28 @@ export interface EventState {
currentAgent: string | null currentAgent: string | null
/** Current model ID from the latest assistant message */ /** Current model ID from the latest assistant message */
currentModel: string | null currentModel: string | null
/** Current model variant from the latest assistant message */
currentVariant: string | null
/** Current message role (user/assistant) — used to filter user messages from display */ /** Current message role (user/assistant) — used to filter user messages from display */
currentMessageRole: string | null currentMessageRole: string | null
/** Agent profile colors keyed by display name */
agentColorsByName: Record<string, string>
/** Part type registry keyed by partID (text, reasoning, tool, ...) */
partTypesById: Record<string, string>
/** Whether a THINK block is currently open in output */
inThinkBlock: boolean
/** Tracks streamed reasoning text to avoid duplicates */
lastReasoningText: string
/** Whether compact thinking line already printed for current reasoning block */
hasPrintedThinkingLine: boolean
/** Last rendered thinking line width (for in-place padding updates) */
lastThinkingLineWidth: number
/** Message role lookup by message ID to filter user parts */
messageRoleById: Record<string, string>
/** Last rendered thinking summary (to avoid duplicate re-render) */
lastThinkingSummary: string
/** Whether text stream is currently at line start (for padding) */
textAtLineStart: boolean
} }
export function createEventState(): EventState { export function createEventState(): EventState {
@ -29,6 +49,16 @@ export function createEventState(): EventState {
messageCount: 0, messageCount: 0,
currentAgent: null, currentAgent: null,
currentModel: null, currentModel: null,
currentVariant: null,
currentMessageRole: null, currentMessageRole: null,
agentColorsByName: {},
partTypesById: {},
inThinkBlock: false,
lastReasoningText: "",
hasPrintedThinkingLine: false,
lastThinkingLineWidth: 0,
messageRoleById: {},
lastThinkingSummary: "",
textAtLineStart: true,
} }
} }

View File

@ -7,6 +7,7 @@ import {
handleSessionIdle, handleSessionIdle,
handleSessionStatus, handleSessionStatus,
handleMessagePartUpdated, handleMessagePartUpdated,
handleMessagePartDelta,
handleMessageUpdated, handleMessageUpdated,
handleToolExecute, handleToolExecute,
handleToolResult, handleToolResult,
@ -38,6 +39,7 @@ export async function processEvents(
handleSessionIdle(ctx, payload, state) handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state) handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state) handleMessagePartUpdated(ctx, payload, state)
handleMessagePartDelta(ctx, payload, state)
handleMessageUpdated(ctx, payload, state) handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state) handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state) handleToolResult(ctx, payload, state)

View File

@ -0,0 +1,466 @@
import { describe, expect, it, spyOn } from "bun:test"
import type { EventPayload, RunContext } from "./types"
import { createEventState } from "./events"
import { processEvents } from "./event-stream-processor"
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
client: {} as RunContext["client"],
sessionID,
directory: "/test",
abortController: new AbortController(),
})
async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
describe("message.part.delta handling", () => {
it("prints streaming text incrementally from delta events", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
field: "text",
delta: "Hello",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
field: "text",
delta: " world",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
expect(state.hasReceivedMeaningfulWork).toBe(true)
expect(state.lastPartText).toBe("Hello world")
expect(stdoutSpy).toHaveBeenCalledTimes(2)
stdoutSpy.mockRestore()
})
it("does not suppress assistant tool/text parts when state role is stale user", () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
state.currentMessageRole = "user"
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const payload: EventPayload = {
type: "message.part.updated",
properties: {
part: {
sessionID: "ses_main",
type: "tool",
tool: "task_create",
state: { status: "running" },
},
},
}
//#when
const { handleMessagePartUpdated } = require("./event-handlers") as {
handleMessagePartUpdated: (ctx: RunContext, payload: EventPayload, state: ReturnType<typeof createEventState>) => void
}
handleMessagePartUpdated(ctx, payload, state)
//#then
expect(state.currentTool).toBe("task_create")
expect(state.hasReceivedMeaningfulWork).toBe(true)
stdoutSpy.mockRestore()
})
it("renders agent header using profile hex color when available", () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
state.agentColorsByName["Sisyphus (Ultraworker)"] = "#00CED1"
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const payload: EventPayload = {
type: "message.updated",
properties: {
info: {
sessionID: "ses_main",
role: "assistant",
agent: "Sisyphus (Ultraworker)",
modelID: "claude-opus-4-6",
variant: "max",
},
},
}
//#when
const { handleMessageUpdated } = require("./event-handlers") as {
handleMessageUpdated: (ctx: RunContext, payload: EventPayload, state: ReturnType<typeof createEventState>) => void
}
handleMessageUpdated(ctx, payload, state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("\u001b[38;2;0;206;209m")
expect(rendered).toContain("claude-opus-4-6 (max)")
expect(rendered).toContain("└─")
expect(rendered).toContain("Sisyphus (Ultraworker)")
stdoutSpy.mockRestore()
})
it("separates think block output from normal response output", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: "Composing final summary in Korean with clear concise structure",
},
},
{
type: "message.part.updated",
properties: {
part: { id: "text-1", sessionID: "ses_main", type: "text", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "text-1",
field: "text",
delta: "answer",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("┃ Thinking: Composing final summary in Korean")
expect(rendered).toContain("answer")
stdoutSpy.mockRestore()
})
it("updates thinking line incrementally on delta updates", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: "Composing final summary",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: " in Korean with specifics.",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("\r")
expect(rendered).toContain("Thinking: Composing final summary")
expect(rendered).toContain("in Korean with specifics.")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not re-render identical thinking summary repeatedly", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", messageID: "msg_assistant", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: "The user wants me",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: " to",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: " ",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const renderCount = rendered.split("Thinking:").length - 1
expect(renderCount).toBe(2)
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not truncate thinking content", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const longThinking = "This is a very long thinking stream that should never be truncated and must include final tail marker END-OF-THINKING-MARKER"
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", messageID: "msg_assistant", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: longThinking,
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("END-OF-THINKING-MARKER")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("applies left and right padding to assistant text output", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6", variant: "max" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "part_assistant_text",
field: "text",
delta: "hello\nworld",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain(" hello \n world")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not render user message parts in output stream", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "part_user_text", messageID: "msg_user", sessionID: "ses_main", type: "text", text: "[search-mode] should not print" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_user",
partID: "part_user_text",
field: "text",
delta: "still should not print",
},
},
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "part_assistant_text",
field: "text",
delta: "assistant output",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered.includes("[search-mode] should not print")).toBe(false)
expect(rendered.includes("still should not print")).toBe(false)
expect(rendered).toContain("assistant output")
stdoutSpy.mockRestore()
})
it("renders tool header and full tool output without truncation", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const longTail = "END-OF-TOOL-OUTPUT-MARKER"
const events: EventPayload[] = [
{
type: "tool.execute",
properties: {
sessionID: "ses_main",
name: "read",
input: { filePath: "src/index.ts", offset: 1, limit: 200 },
},
},
{
type: "tool.result",
properties: {
sessionID: "ses_main",
name: "read",
output: `line1\nline2\n${longTail}`,
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("→")
expect(rendered).toContain("Read src/index.ts")
expect(rendered).toContain("END-OF-TOOL-OUTPUT-MARKER")
stdoutSpy.mockRestore()
})
})

View File

@ -0,0 +1,104 @@
import pc from "picocolors"
export function renderAgentHeader(
agent: string | null,
model: string | null,
variant: string | null,
agentColorsByName: Record<string, string>,
): void {
if (!agent && !model) return
const agentLabel = agent
? pc.bold(colorizeWithProfileColor(agent, agentColorsByName[agent]))
: ""
const modelBase = model ?? ""
const variantSuffix = variant ? ` (${variant})` : ""
const modelLabel = model ? pc.dim(`${modelBase}${variantSuffix}`) : ""
if (modelLabel) {
process.stdout.write(` ${modelLabel} \n`)
}
if (agentLabel) {
process.stdout.write(` ${pc.dim("└─")} ${agentLabel} \n`)
}
}
export function openThinkBlock(): void {
return
}
export function closeThinkBlock(): void {
process.stdout.write("\n")
}
export function renderThinkingLine(
summary: string,
previousWidth: number,
): number {
const line = ` ┃ Thinking: ${summary} `
const isGitHubActions = process.env.GITHUB_ACTIONS === "true"
if (isGitHubActions) {
process.stdout.write(`${pc.dim(line)}\n`)
return line.length
}
const minPadding = 6
const clearPadding = Math.max(previousWidth - line.length + minPadding, minPadding)
process.stdout.write(`\r${pc.dim(line)}${" ".repeat(clearPadding)}`)
return line.length
}
export function writePaddedText(
text: string,
atLineStart: boolean,
): { output: string; atLineStart: boolean } {
const isGitHubActions = process.env.GITHUB_ACTIONS === "true"
if (isGitHubActions) {
return { output: text, atLineStart: text.endsWith("\n") }
}
let output = ""
let lineStart = atLineStart
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (lineStart) {
output += " "
lineStart = false
}
if (ch === "\n") {
output += " \n"
lineStart = true
continue
}
output += ch
}
return { output, atLineStart: lineStart }
}
function colorizeWithProfileColor(text: string, hexColor?: string): string {
if (!hexColor) return pc.magenta(text)
const rgb = parseHexColor(hexColor)
if (!rgb) return pc.magenta(text)
const [r, g, b] = rgb
return `\u001b[38;2;${r};${g};${b}m${text}\u001b[39m`
}
function parseHexColor(hexColor: string): [number, number, number] | null {
const cleaned = hexColor.trim()
const match = cleaned.match(/^#?([A-Fa-f0-9]{6})$/)
if (!match) return null
const hex = match[1]
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return [r, g, b]
}

View File

@ -8,6 +8,7 @@ import { createJsonOutputManager } from "./json-output"
import { executeOnCompleteHook } from "./on-complete-hook" import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver" import { resolveRunAgent } from "./agent-resolver"
import { pollForCompletion } from "./poll-for-completion" import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
export { resolveRunAgent } export { resolveRunAgent }
@ -76,11 +77,11 @@ export async function run(options: RunOptions): Promise<number> {
} }
const events = await client.event.subscribe({ query: { directory } }) const events = await client.event.subscribe({ query: { directory } })
const eventState = createEventState() const eventState = createEventState()
eventState.agentColorsByName = await loadAgentProfileColors(client)
const eventProcessor = processEvents(ctx, events.stream, eventState).catch( const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {}, () => {},
) )
console.log(pc.dim("\nSending prompt..."))
await client.session.promptAsync({ await client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
@ -89,8 +90,6 @@ export async function run(options: RunOptions): Promise<number> {
}, },
query: { directory }, query: { directory },
}) })
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController) const exitCode = await pollForCompletion(ctx, eventState, abortController)
// Abort the event stream to stop the processor // Abort the event stream to stop the processor

View File

@ -1,38 +1,144 @@
import pc from "picocolors" export interface ToolHeader {
icon: string
const SINGLE_VALUE_FIELDS = ["command", "filePath"] as const title: string
description?: string
const MULTI_VALUE_FIELDS = [ }
"description",
"pattern", export function formatToolHeader(toolName: string, input: Record<string, unknown>): ToolHeader {
"query", if (toolName === "glob") {
"url", const pattern = str(input.pattern)
"category", const root = str(input.path)
"subagent_type", return {
"lang", icon: "✱",
"run_in_background", title: pattern ? `Glob "${pattern}"` : "Glob",
] as const description: root ? `in ${root}` : undefined,
}
export function formatToolInputPreview(input: Record<string, unknown>): string { }
for (const key of SINGLE_VALUE_FIELDS) {
if (!input[key]) continue if (toolName === "grep") {
const maxLen = key === "command" ? 80 : 120 const pattern = str(input.pattern)
return ` ${pc.dim(String(input[key]).slice(0, maxLen))}` const root = str(input.path)
} return {
icon: "✱",
const parts: string[] = [] title: pattern ? `Grep "${pattern}"` : "Grep",
let totalLen = 0 description: root ? `in ${root}` : undefined,
}
for (const key of MULTI_VALUE_FIELDS) { }
const val = input[key]
if (val === undefined || val === null) continue if (toolName === "list") {
const str = String(val) const path = str(input.path)
const truncated = str.length > 50 ? str.slice(0, 47) + "..." : str return {
const entry = `${key}=${truncated}` icon: "→",
if (totalLen + entry.length > 120) break title: path ? `List ${path}` : "List",
parts.push(entry) }
totalLen += entry.length + 1 }
}
if (toolName === "read") {
return parts.length > 0 ? ` ${pc.dim(parts.join(" "))}` : "" const filePath = str(input.filePath)
return {
icon: "→",
title: filePath ? `Read ${filePath}` : "Read",
description: formatKeyValues(input, ["filePath"]),
}
}
if (toolName === "write") {
const filePath = str(input.filePath)
return {
icon: "←",
title: filePath ? `Write ${filePath}` : "Write",
}
}
if (toolName === "edit") {
const filePath = str(input.filePath)
return {
icon: "←",
title: filePath ? `Edit ${filePath}` : "Edit",
description: formatKeyValues(input, ["filePath", "oldString", "newString"]),
}
}
if (toolName === "webfetch") {
const url = str(input.url)
return {
icon: "%",
title: url ? `WebFetch ${url}` : "WebFetch",
description: formatKeyValues(input, ["url"]),
}
}
if (toolName === "websearch_web_search_exa") {
const query = str(input.query)
return {
icon: "◈",
title: query ? `Web Search "${query}"` : "Web Search",
}
}
if (toolName === "grep_app_searchGitHub") {
const query = str(input.query)
return {
icon: "◇",
title: query ? `Code Search "${query}"` : "Code Search",
}
}
if (toolName === "task") {
const desc = str(input.description)
const subagent = str(input.subagent_type)
return {
icon: "#",
title: desc || (subagent ? `${subagent} Task` : "Task"),
description: subagent ? `agent=${subagent}` : undefined,
}
}
if (toolName === "bash") {
const command = str(input.command)
return {
icon: "$",
title: command || "bash",
description: formatKeyValues(input, ["command"]),
}
}
if (toolName === "skill") {
const name = str(input.name)
return {
icon: "→",
title: name ? `Skill "${name}"` : "Skill",
}
}
if (toolName === "todowrite") {
return {
icon: "#",
title: "Todos",
}
}
return {
icon: "⚙",
title: toolName,
description: formatKeyValues(input, []),
}
}
function formatKeyValues(input: Record<string, unknown>, exclude: string[]): string | undefined {
const entries = Object.entries(input).filter(([key, value]) => {
if (exclude.includes(key)) return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
if (!entries.length) return undefined
return entries
.map(([key, value]) => `${key}=${String(value)}`)
.join(" ")
}
function str(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed.length ? trimmed : undefined
} }

View File

@ -67,12 +67,14 @@ export interface SessionStatusProps {
export interface MessageUpdatedProps { export interface MessageUpdatedProps {
info?: { info?: {
id?: string
sessionID?: string sessionID?: string
sessionId?: string sessionId?: string
role?: string role?: string
modelID?: string modelID?: string
providerID?: string providerID?: string
agent?: string agent?: string
variant?: string
} }
} }
@ -96,6 +98,15 @@ export interface MessagePartUpdatedProps {
} }
} }
export interface MessagePartDeltaProps {
sessionID?: string
sessionId?: string
messageID?: string
partID?: string
field?: string
delta?: string
}
export interface ToolExecuteProps { export interface ToolExecuteProps {
sessionID?: string sessionID?: string
sessionId?: string sessionId?: string