diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 6c139e9..2953ae2 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -1005,13 +1005,17 @@ describe('OpenCodeInlinePrompt', () => { warnSpy.mockRestore(); }); - it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => { - // Regression: previously the streaming path appended deltas to - // .textContent, so the user saw raw ```py\n...\n``` source while - // the model was still typing. Only after closing+reopening the - // panel (which goes through setMessages + marked.parse) did the - // code block render as
. The fix: re-render via
- // marked.parse on every delta.
+ it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
+ // Regression: a previous version ran marked.parse on every text
+ // delta. That produced glitchy partial-render states (e.g. an
+ // opening ```py fence with no closing fence yet is a different
+ // DOM shape than the complete code block, so the toolbar
+ // post-processing flickered in and out between deltas). The fix
+ // is to keep the streaming path dumb (append raw text only) and
+ // run the markdown render exactly once at the end of the turn
+ // (session.idle). The user sees real-time text grow during the
+ // stream, and a clean rendered view the moment the turn ends —
+ // matching what setMessages would produce from the stored history.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -1020,43 +1024,58 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setSessionId('s1');
- // Simulate a streaming reply that contains a fenced code block.
- const deltas = [
- 'Here is the fix:\n\n',
- '```python\n',
- 'import pandas as pd\n',
- 'import numpy as np\n',
- '```\n'
- ];
- for (const d of deltas) {
+ const fullSource =
+ 'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
+ // Stream every delta of the source.
+ for (const d of fullSource.split(/(?=H|```|\n)/)) {
+ if (!d) continue;
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
- // The block is rendered DURING streaming, not just at
- // the end. The raw ```fence``` characters must NOT appear as visible
- // text content in the message body.
+ // During streaming: raw text, NO markdown render yet.
+ const asstDuring = prompt.node.querySelector(
+ '.opencode-msg-assistant'
+ ) as HTMLElement;
+ expect(asstDuring.textContent).toBe(fullSource);
+ // The ```fence``` characters are present as literal text — the
+ // user is looking at the source, not the rendered view.
+ expect(asstDuring.textContent).toContain('```python');
+ // No element yet (no markdown render was attempted).
+ expect(asstDuring.querySelector('pre')).toBeNull();
+ // No toolbar yet either.
+ expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
+ // Still a single assistant element (no forks per delta).
+ expect(
+ prompt.node.querySelectorAll(
+ '.opencode-msg-assistant'
+ ).length
+ ).toBe(1);
+
+ // Turn end -> session.idle triggers the final markdown render
+ // (via _resetStreamPointers, which the prompt invokes before
+ // calling onStreamEnd).
+ prompt.applyEvent({
+ type: 'session.idle',
+ properties: { sessionID: 's1' }
+ });
+
+ // After idle: the assistant message is now a fully rendered
+ // markdown DOM, identical to what setMessages would produce.
const pre = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-content pre'
) as HTMLElement;
expect(pre).not.toBeNull();
const code = pre.querySelector('code') as HTMLElement;
- expect(code).not.toBeNull();
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
// The Copy / Insert / Replace toolbar is attached to the
- // block during streaming, same as in setMessages.
+ // block on the final render, same as in setMessages.
const toolbar = pre.parentElement!.querySelector(
'.opencode-code-toolbar'
) as HTMLElement;
expect(toolbar).not.toBeNull();
expect(toolbar.querySelectorAll('button').length).toBe(3);
- // The wrapper still has a single assistant element (not one per delta).
- expect(
- prompt.node.querySelectorAll(
- '.opencode-inline-prompt .opencode-msg-assistant'
- ).length
- ).toBe(1);
});
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts
index f04a155..d0785a4 100644
--- a/src/components/opencode_inline_prompt.ts
+++ b/src/components/opencode_inline_prompt.ts
@@ -596,16 +596,19 @@ export class OpenCodeInlinePrompt extends Widget {
if (!this._currentAssistantEl) {
this._currentAssistantEl = this._createMessageElement('assistant', '');
}
- // Accumulate the raw markdown source, then re-render the whole
- // message through marked.parse. This way the user sees properly
- // formatted output (code blocks as , headings, lists)
- // in real-time as text deltas arrive — NOT the raw ```fence```
- // source that an append-only .textContent would show.
+ // During streaming, append the raw delta to the assistant element's
+ // textContent. We do NOT run marked.parse on every delta: the
+ // partial source (e.g. an opening ```py fence with no closing
+ // fence yet) is structurally different from the complete message
+ // and the toolbar post-processing creates visible flicker —
+ // s appear and disappear as the renderer re-classifies the
+ // incomplete markdown between deltas. The user gets stable
+ // real-time text here, and the full markdown render happens once
+ // at the end of the turn (see _resetStreamPointers) so the result
+ // is identical to what setMessages would produce from the stored
+ // history.
this._currentAssistantText += text;
- this._renderAssistantMarkdown(
- this._currentAssistantEl,
- this._currentAssistantText
- );
+ this._currentAssistantEl.textContent = this._currentAssistantText;
this._scrollToBottom();
}
@@ -807,6 +810,19 @@ export class OpenCodeInlinePrompt extends Widget {
}
private _resetStreamPointers(): void {
+ // Finalize the in-progress assistant message by running the
+ // markdown render ONCE on the accumulated raw text. This is the
+ // moment where the streaming path converges to the same DOM
+ // structure that setMessages would produce from the stored
+ // history (i.e. identical to "reopen" behavior). Without this
+ // final pass the user would see raw ```fence``` source
+ // permanently until they close+reopen the panel.
+ if (this._currentAssistantEl && this._currentAssistantText) {
+ this._renderAssistantMarkdown(
+ this._currentAssistantEl,
+ this._currentAssistantText
+ );
+ }
this._currentAssistantEl = null;
this._currentAssistantText = '';
this._activeBlocks = {};