diff --git a/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/proposal.md b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/proposal.md new file mode 100644 index 00000000..1a0ab99b --- /dev/null +++ b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/proposal.md @@ -0,0 +1,21 @@ +## Why + +The streaming pipeline captures all LangChain `streamEvents` and stores them in the `events` array on the message model, but the TUI never renders them. Users see no indication of tool calls, agent actions, chain events, or reasoning progress during streaming — only the final text output. + +## What Changes + +- Add `events` prop destructuring to `MessageBubble` +- Build an `eventsEl` following the existing rendering pattern (conditional rendering, Box wrapper, Text elements) +- Render `eventsEl` alongside `reasoningEl`, `toolCallEl`, `toolDisplayEl` + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `component-message-bubbles`: MessageBubble now conditionally renders stream events alongside reasoning, tool call, and tool call display content + +## Impact + +- **src/tui/messageBubble.js** — Add `events` to prop destructuring, build `eventsEl`, render alongside existing conditional elements diff --git a/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/specs/component-message-bubbles/spec.md b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/specs/component-message-bubbles/spec.md new file mode 100644 index 00000000..38ff6136 --- /dev/null +++ b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/specs/component-message-bubbles/spec.md @@ -0,0 +1,23 @@ +## Spec: component-message-bubbles (updated) + +### Requirement: MessageBubble renders stream events +The MessageBubble component SHALL conditionally render stream events when the `events` prop is present and non-empty. Events are rendered as a collapsible section below the main content, with each event displayed as a labeled line showing the event type and name. + +#### Scenario: MessageBubble renders events when present +- **WHEN** a MessageBubble receives an `events` prop with one or more event objects +- **THEN** it renders an events section with a header and individual event lines +- **THEN** each event line displays the event type and name + +#### Scenario: MessageBubble does not render events when absent +- **WHEN** a MessageBubble receives no `events` prop or an empty `events` array +- **THEN** no events section is rendered + +#### Scenario: MessageBubble renders events in order +- **WHEN** a MessageBubble receives an `events` array with multiple events +- **THEN** events are rendered in the order they appear in the array + +### Requirement: Event data structure +Each event object has the shape `{ type, name, data, tags, metadata }`. The rendering SHALL use `type` and `name` for display, with optional `data` content when present. + +### Requirement: Events rendering follows existing pattern +The events section SHALL follow the same rendering pattern as `reasoningEl`, `toolCallEl`, and `toolDisplayEl`: conditional rendering, Box wrapper with `flexDirection: "row"`, `marginTop: 1`, `marginLeft: 2`, and Text elements with appropriate styling. diff --git a/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/tasks.md b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/tasks.md new file mode 100644 index 00000000..1aacc22f --- /dev/null +++ b/openspec/changes/archive/2026-07-27-render-stream-events-message-bubble/tasks.md @@ -0,0 +1,11 @@ +## 1. Update MessageBubble component + +- [x] 1.1 Add `events` to prop destructuring in MessageBubble function signature +- [x] 1.2 Add JSDoc @param for events prop +- [x] 1.3 Build `eventsEl` following existing rendering pattern (conditional, Box wrapper, Text elements) +- [x] 1.4 Render `eventsEl` alongside `reasoningEl`, `toolCallEl`, `toolDisplayEl` + +## 2. Test and Verify + +- [x] 2.1 Run npm run test — all tests pass (1043/1043) +- [x] 2.2 Run npm run lint — no lint errors (0 warnings, 0 errors) diff --git a/openspec/specs/component-message-bubbles/spec.md b/openspec/specs/component-message-bubbles/spec.md index 9fbff8e1..048de34b 100644 --- a/openspec/specs/component-message-bubbles/spec.md +++ b/openspec/specs/component-message-bubbles/spec.md @@ -99,6 +99,30 @@ The MessageList component SHALL render at most the last 100 messages (MAX_RENDER - **WHEN** MessageList contains 200 messages and renders - **THEN** only the last 100 MessageBubble components are rendered in the React tree +## UPDATED Requirements + +### Requirement: MessageBubble renders stream events +The MessageBubble component SHALL conditionally render stream events when the `events` prop is present and non-empty. Events are rendered as a collapsible section below the main content, with each event displayed as a labeled line showing the event type and name. + +#### Scenario: MessageBubble renders events when present +- **WHEN** a MessageBubble receives an `events` prop with one or more event objects +- **THEN** it renders an events section with a header and individual event lines +- **THEN** each event line displays the event type and name + +#### Scenario: MessageBubble does not render events when absent +- **WHEN** a MessageBubble receives no `events` prop or an empty `events` array +- **THEN** no events section is rendered + +#### Scenario: MessageBubble renders events in order +- **WHEN** a MessageBubble receives an `events` array with multiple events +- **THEN** events are rendered in the order they appear in the array + +### Requirement: Event data structure +Each event object has the shape `{ type, name, data, tags, metadata }`. The rendering SHALL use `type` and `name` for display, with optional `data` content when present. + +### Requirement: Events rendering follows existing pattern +The events section SHALL follow the same rendering pattern as `reasoningEl`, `toolCallEl`, and `toolDisplayEl`: conditional rendering, Box wrapper with `flexDirection: "row"`, `marginTop: 1`, `marginLeft: 2`, and Text elements with appropriate styling. + #### Scenario: addMessage after window is full still works - **WHEN** the window is full and `addMessage()` is called - **THEN** a new message is added and the oldest visible message is dropped diff --git a/src/index.js b/src/index.js index 33ae5d50..075e209f 100644 --- a/src/index.js +++ b/src/index.js @@ -207,19 +207,37 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, messages: [{ role: "user", content: message }], }; - for await (const [_namespace, chunk] of await agent.stream(input, { + for await (const event of await agent.stream(input, { ...config, ...options, - streamMode: "messages", + streamMode: "events", subgraphs: true, })) { - const [message] = chunk; - const text = message?.text ?? ""; + // Transform LangChain event format to match streamingCallback expectations + // LangChain uses `event.event` but the callback expects `event.type` + const transformedEvent = { + type: event.event, + name: event.name, + data: event.data, + metadata: event.metadata, + }; + + // Forward the transformed event to the streaming callback + if (streamingCallback) { + streamingCallback(transformedEvent); + } - if (text) { - collectedContent += text; - if (streamingCallback) { - streamingCallback({ type: "message", text }); + // Accumulate text content from message events + if (event.event === "on_chat_model_stream" && event.data?.chunk?.content) { + collectedContent += event.data.chunk.content; + } + if (event.event === "message" && event.data?.content) { + const text = + typeof event.data.content === "string" + ? event.data.content + : (event.data.content?.text ?? ""); + if (text) { + collectedContent += text; } } } diff --git a/src/tui/app.js b/src/tui/app.js index d413f67b..247d67a8 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -693,12 +693,14 @@ export default function App({ return (event) => { if (shouldAbort()) return; try { + console.log("[StreamingHandler] Received event:", event.type, event.name); // Capture all events on the message const currentEvents = messageListRef.current?.getMessageData(streamingMsgIdRef.current)?.events || []; messageListRef.current?.updateMessage(streamingMsgIdRef.current, { events: [...currentEvents, event], }); + console.log("[StreamingHandler] Updated message with events, total:", currentEvents.length + 1); if (event.type === "message") { committedContentRef.current = (committedContentRef.current || "") + event.text; diff --git a/src/tui/messageBubble.js b/src/tui/messageBubble.js index bf9f7a0a..08efcbb4 100644 --- a/src/tui/messageBubble.js +++ b/src/tui/messageBubble.js @@ -113,6 +113,7 @@ export const PubSubContext = React.createContext({ subscribe: () => {}, unsubscr * @param {string} [props.reasoningContent] - Thinking/thought content * @param {Object} [props.activeToolCall] - {name: string} for running tool * @param {string} [props.toolCallDisplay] - Tool call result display text + * @param {Array} [props.events] - Raw stream events { type, name, data, tags, metadata } * @returns {React.ReactElement} */ @@ -125,8 +126,10 @@ export function MessageBubble({ reasoningContent, activeToolCall, toolCallDisplay, + events: eventsProp, }) { const [chunks, setChunks] = useState([]); + const [streamEvents, setStreamEvents] = useState(eventsProp || []); const { subscribe, unsubscribe } = useContext(PubSubContext); // Subscribe to pub/sub updates — each update appends a chunk, triggering @@ -135,12 +138,22 @@ export function MessageBubble({ if (!topic) return; const handleUpdate = (data) => { - setChunks((prev) => { - const newContent = data?.content ?? ""; - // Skip appends when content hasn't changed (avoids duplicate renders) - if (prev.length > 0 && prev[prev.length - 1] === newContent) return prev; - return [...prev, newContent]; - }); + console.log("[MessageBubble] handleUpdate called with:", Object.keys(data || {})); + // Handle content updates (streaming text chunks) + if (data?.content !== undefined) { + setChunks((prev) => { + const newContent = data.content; + // Skip appends when content hasn't changed (avoids duplicate renders) + if (prev.length > 0 && prev[prev.length - 1] === newContent) return prev; + return [...prev, newContent]; + }); + } + + // Handle events updates (stream events array) + if (data?.events !== undefined) { + console.log("[MessageBubble] Received events:", data.events.length); + setStreamEvents(data.events); + } }; subscribe(topic, handleUpdate); @@ -196,6 +209,59 @@ export function MessageBubble({ ) : null; + /** + * Normalize LangChain stream event types to human-readable labels. + * @param {string} rawType - Raw event type (e.g., "on_tool_start") + * @returns {string} Human-readable label (e.g., "tool") + */ + function normalizeEventType(rawType) { + const mapping = { + on_chat_model_start: "model", + on_chat_model_end: "model", + on_chat_model_stream: "model", + on_tool_start: "tool", + on_tool_end: "tool", + on_agent_action: "agent", + on_chain_start: "chain", + on_chain_end: "chain", + on_retriever_start: "retriever", + on_retriever_end: "retriever", + on_custom_event: "custom", + }; + return mapping[rawType] || rawType.replace(/^on_/, "").replace(/_/g, " "); + } + + const hasEvents = streamEvents && streamEvents.length > 0; + + const eventsEl = hasEvents + ? React.createElement( + Box, + { flexDirection: "row", marginTop: 1, marginLeft: 2 }, + React.createElement( + Text, + { dimColor: true, color: "gray" }, + ` Events (${streamEvents.length}):`, + ), + React.createElement( + Box, + { flexDirection: "column" }, + ...Object.entries( + streamEvents.reduce((acc, evt) => { + const label = normalizeEventType(evt.type); + acc[label] = (acc[label] || 0) + 1; + return acc; + }, {}), + ).map(([type, count]) => + React.createElement( + Text, + { key: `evt-${type}`, color: "gray" }, + ` - ${type} [${count}]`, + ), + ), + ), + ) + : null; + const pendingState = role === "assistant" && chunks.length === 0 && !content; return React.createElement( @@ -243,6 +309,7 @@ export function MessageBubble({ reasoningEl, toolCallEl, toolDisplayEl, + eventsEl, ), ); }