Skip to content

Commit 0290c3f

Browse files
committed
fix(repl): render markdown after streaming completes instead of pushing raw chunks
During streaming, text now accumulates in the dynamic area (live preview). When streaming finishes, the full response is rendered through renderMarkdown() (marked + marked-terminal) producing styled tables, headings, and code blocks, then frozen as one Static item. Previously, raw text chunks were pushed to <Static> via pushChunk() without markdown rendering, causing tables to appear as pipe-delimited plain text. Assisted-by: Claude:claude-opus-4-6 Signed-off-by: Sri Aradhyula <sraradhy@cisco.com>
1 parent 42d51b1 commit 0290c3f

1 file changed

Lines changed: 51 additions & 43 deletions

File tree

cli/src/chat/Repl.tsx

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* CAIPE interactive chat REPL built with Ink 5 + React 18.
33
*
44
* Architecture:
5-
* - ALL visible text (user prompts, streamed chunks, completed responses)
6-
* goes into Ink's <Static> — rendered once, never redrawn.
7-
* - The "dynamic" area (redrawn on state changes) contains ONLY the
8-
* input bar + status footer — typically 3-4 lines, no flashing.
9-
* - Markdown is rendered incrementally: each streamed chunk is passed
10-
* through renderMarkdown() once when flushed, then frozen in <Static>.
5+
* - Completed messages (user prompts, rendered assistant responses)
6+
* go into Ink's <Static> — rendered once, never redrawn.
7+
* - During streaming, raw text accumulates in the "dynamic" area
8+
* (redrawn on state changes) so the user sees tokens as they arrive.
9+
* - When streaming finishes, the full response is rendered through
10+
* renderMarkdown() (tables, code blocks, styled headings) and pushed
11+
* as one <Static> item — no flashing, proper formatting.
1112
*/
1213

1314
import { Box, Static, Text, useApp, useInput } from "ink";
@@ -464,6 +465,7 @@ export function Repl({
464465
const [statusText, setStatusText] = useState<string | null>(null);
465466
const [pickerIndex, setPickerIndex] = useState(0);
466467
const [activeToolName, setActiveToolName] = useState<string | null>(null);
468+
const [streamingText, setStreamingText] = useState("");
467469
const tokenCountRef = useRef(0);
468470
const streamStartRef = useRef(0);
469471
const ctrlDCountRef = useRef(0);
@@ -498,23 +500,17 @@ export function Repl({
498500
[pushStatic],
499501
);
500502

501-
const pushChunk = useCallback(
502-
(text: string) => {
503-
pushStatic({ kind: "chunk", text });
504-
},
505-
[pushStatic],
506-
);
507-
508503
const _pushTool = useCallback(
509504
(name: string) => {
510505
pushStatic({ kind: "tool", name });
511506
},
512507
[pushStatic],
513508
);
514509

515-
// ── Flush buffered streaming tokens into Static as complete lines ──
516-
// Partial lines are held in lineBufferRef until a newline arrives,
517-
// so words never break mid-line across Static items.
510+
// ── Flush buffered streaming tokens ──
511+
// Tokens accumulate in accumulatedRef and are displayed in the dynamic
512+
// area via streamingText state. When streaming ends, the full text is
513+
// rendered through renderMarkdown() and pushed as one Static item.
518514
const flushTokens = useCallback(() => {
519515
const text = pendingTokensRef.current;
520516
const count = pendingTokenCountRef.current;
@@ -524,24 +520,23 @@ export function Repl({
524520
tokenCountRef.current += count;
525521
accumulatedRef.current += text;
526522
setStreamTokenCount((prev) => prev + count);
523+
setStreamingText(accumulatedRef.current);
524+
}, []);
527525

528-
lineBufferRef.current += text;
529-
const lastNl = lineBufferRef.current.lastIndexOf("\n");
530-
if (lastNl !== -1) {
531-
// Push complete lines to Static — the remainder stays in the buffer
532-
const completeLines = lineBufferRef.current.slice(0, lastNl + 1);
533-
lineBufferRef.current = lineBufferRef.current.slice(lastNl + 1);
534-
pushChunk(completeLines);
535-
}
536-
}, [pushChunk]);
537-
538-
// Flush whatever remains in the line buffer (called when streaming ends)
526+
// Flush whatever remains in pending tokens (called when streaming ends)
539527
const flushLineBuffer = useCallback(() => {
540-
if (lineBufferRef.current) {
541-
pushChunk(lineBufferRef.current);
542-
lineBufferRef.current = "";
528+
// Any pending tokens not yet flushed
529+
const text = pendingTokensRef.current;
530+
if (text) {
531+
pendingTokensRef.current = "";
532+
const count = pendingTokenCountRef.current;
533+
pendingTokenCountRef.current = 0;
534+
tokenCountRef.current += count;
535+
accumulatedRef.current += text;
536+
setStreamTokenCount((prev) => prev + count);
543537
}
544-
}, [pushChunk]);
538+
lineBufferRef.current = "";
539+
}, []);
545540

546541
// ── Slash picker ──
547542
const filteredCommands = useMemo<SlashCommand[]>(() => {
@@ -751,11 +746,11 @@ export function Repl({
751746
pushUser(text);
752747
tokenCountRef.current += Math.ceil(prompt.length / 4);
753748

754-
// Start streaming — push the ⏺ prefix as the first chunk
749+
// Start streaming — show response in dynamic area until complete
755750
accumulatedRef.current = "";
756751
setStreamTokenCount(0);
752+
setStreamingText("");
757753
setStreaming(true);
758-
pushStatic({ kind: "chunk", text: "" }); // spacer
759754

760755
try {
761756
const gen = adapter.connect({
@@ -787,32 +782,35 @@ export function Repl({
787782
}
788783
}
789784

790-
// Flush remaining buffered tokens + partial line
785+
// Flush remaining buffered tokens
791786
if (flushTimerRef.current) {
792787
clearTimeout(flushTimerRef.current);
793788
flushTimerRef.current = null;
794789
}
795790
flushTokens();
796791
flushLineBuffer();
797792

798-
// If pipe was requested, filter the accumulated response
793+
// Clear streaming display and push markdown-rendered result to Static
794+
setStreamingText("");
795+
799796
let finalContent = accumulatedRef.current;
800797
if (parsed.pipeCmd && finalContent) {
801798
setStatusText(`Piping through: ${parsed.pipeCmd}`);
802799
finalContent = await pipeThrough(finalContent, parsed.pipeCmd);
803-
// Show piped result as a completed assistant message
804-
pushAssistant(finalContent);
805800
}
806801

807-
// Store full response in history for context
808-
historyRef.current.push({ role: "assistant", content: accumulatedRef.current });
802+
// Render and freeze the complete response in Static
803+
if (finalContent) {
804+
pushAssistant(finalContent);
805+
}
809806
} catch (err) {
810807
const msg = err instanceof Error ? err.message : String(err);
811808
pushAssistant(`[ERROR] ${msg}`);
812809
} finally {
813810
pendingTokensRef.current = "";
814811
pendingTokenCountRef.current = 0;
815812
lineBufferRef.current = "";
813+
setStreamingText("");
816814
setStreaming(false);
817815
setActiveToolName(null);
818816
setStatusText(null);
@@ -827,7 +825,6 @@ export function Repl({
827825
flushLineBuffer,
828826
pushUser,
829827
pushAssistant,
830-
pushStatic,
831828
],
832829
);
833830

@@ -917,13 +914,24 @@ export function Repl({
917914
}}
918915
</Static>
919916

920-
{/* Dynamic area: ONLY the input + status — tiny, no flashing */}
917+
{/* Dynamic area: streaming text + input + status */}
921918
<Box flexDirection="column" flexGrow={1} paddingY={0}>
922-
{staticItems.length === 0 && !streaming && (
919+
{streaming && streamingText ? (
920+
<Box paddingX={1} flexDirection="column">
921+
<Box>
922+
<Text color="blue">{"⏺ "}</Text>
923+
</Box>
924+
<Text>{streamingText}</Text>
925+
</Box>
926+
) : staticItems.length === 0 && !streaming ? (
923927
<Box paddingX={1}>
924928
<Text dimColor>Type a message or / for commands.</Text>
925929
</Box>
926-
)}
930+
) : streaming ? (
931+
<Box paddingX={1}>
932+
<Text color="blue">{"⏺ "}</Text>
933+
</Box>
934+
) : null}
927935
</Box>
928936

929937
{showPicker && (

0 commit comments

Comments
 (0)