Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
<!-- None — existing tui-streaming capability already covers event capture -->

### 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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions openspec/specs/component-message-bubbles/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/tui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,12 +693,14 @@
return (event) => {
if (shouldAbort()) return;
try {
console.log("[StreamingHandler] Received event:", event.type, event.name);

Check failure on line 696 in src/tui/app.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-console)

Unexpected console statement.
// 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);

Check failure on line 703 in src/tui/app.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-console)

Unexpected console statement.

if (event.type === "message") {
committedContentRef.current = (committedContentRef.current || "") + event.text;
Expand Down
79 changes: 73 additions & 6 deletions src/tui/messageBubble.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
* @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<Object>} [props.events] - Raw stream events { type, name, data, tags, metadata }

* @returns {React.ReactElement}
*/
Expand All @@ -125,8 +126,10 @@
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
Expand All @@ -135,12 +138,22 @@
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 || {}));

Check failure on line 141 in src/tui/messageBubble.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-console)

Unexpected console statement.
// 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);

Check failure on line 154 in src/tui/messageBubble.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-console)

Unexpected console statement.
setStreamEvents(data.events);
}
};

subscribe(topic, handleUpdate);
Expand Down Expand Up @@ -196,6 +209,59 @@
)
: 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(
Expand Down Expand Up @@ -243,6 +309,7 @@
reasoningEl,
toolCallEl,
toolDisplayEl,
eventsEl,
),
);
}
Expand Down
Loading