diff --git a/packages/core/src/renderables/Markdown.ts b/packages/core/src/renderables/Markdown.ts index 614615af5..b9c145c0a 100644 --- a/packages/core/src/renderables/Markdown.ts +++ b/packages/core/src/renderables/Markdown.ts @@ -67,6 +67,7 @@ export class MarkdownRenderable extends Renderable { super(ctx, { ...options, flexDirection: "column", + flexShrink: options.flexShrink ?? 0, }) this._syntaxStyle = options.syntaxStyle @@ -328,48 +329,6 @@ export class MarkdownRenderable extends Renderable { return chunks } - private renderBlockquoteChunks(token: Tokens.Blockquote): TextChunk[] { - const chunks: TextChunk[] = [] - for (const child of token.tokens) { - chunks.push(this.createChunk("> ", "punctuation.special")) - const childChunks = this.renderTokenToChunks(child as MarkedToken) - chunks.push(...childChunks) - chunks.push(this.createDefaultChunk("\n")) - } - return chunks - } - - private renderListChunks(token: Tokens.List): TextChunk[] { - const chunks: TextChunk[] = [] - let index = typeof token.start === "number" ? token.start : 1 - - for (const item of token.items) { - if (token.ordered) { - chunks.push(this.createChunk(`${index}. `, "markup.list")) - index++ - } else { - chunks.push(this.createChunk("- ", "markup.list")) - } - - for (let i = 0; i < item.tokens.length; i++) { - const child = item.tokens[i] - if (child.type === "text" && i === 0 && "tokens" in child && child.tokens) { - this.renderInlineContent(child.tokens, chunks) - chunks.push(this.createDefaultChunk("\n")) - } else if (child.type === "paragraph" && i === 0) { - this.renderInlineContent((child as Tokens.Paragraph).tokens, chunks) - chunks.push(this.createDefaultChunk("\n")) - } else { - const childChunks = this.renderTokenToChunks(child as MarkedToken) - chunks.push(...childChunks) - chunks.push(this.createDefaultChunk("\n")) - } - } - } - - return chunks - } - private renderThematicBreakChunks(): TextChunk[] { return [this.createChunk("---", "punctuation.special")] } @@ -381,9 +340,7 @@ export class MarkdownRenderable extends Renderable { case "paragraph": return this.renderParagraphChunks(token) case "blockquote": - return this.renderBlockquoteChunks(token) - case "list": - return this.renderListChunks(token) + return [] case "hr": return this.renderThematicBreakChunks() case "space": @@ -396,6 +353,35 @@ export class MarkdownRenderable extends Renderable { } } + private createBlockRenderable(token: MarkedToken, id: string, marginBottom: number): Renderable | null { + if (token.type === "code") { + return this.createCodeRenderable(token as Tokens.Code, id, marginBottom) + } + + if (token.type === "table") { + return this.createTableRenderable(token as Tokens.Table, id, marginBottom) + } + + if (token.type === "list") { + return this.createListRenderable(token as Tokens.List, id, marginBottom) + } + + if (token.type === "blockquote") { + return this.createBlockquoteRenderable(token as Tokens.Blockquote, id, marginBottom) + } + + if (token.type === "space") { + return this.createTextRenderable([this.createDefaultChunk(" ")], id, marginBottom) + } + + const chunks = this.renderTokenToChunks(token) + if (chunks.length === 0) { + return null + } + + return this.createTextRenderable(chunks, id, marginBottom) + } + private createTextRenderable(chunks: TextChunk[], id: string, marginBottom: number = 0): TextRenderable { return new TextRenderable(this.ctx, { id, @@ -418,6 +404,85 @@ export class MarkdownRenderable extends Renderable { }) } + private createListRenderable(token: Tokens.List, id: string, marginBottom: number = 0): Renderable { + const listBox = new BoxRenderable(this.ctx, { + id, + width: "100%", + flexDirection: "column", + marginBottom, + }) + + let index = typeof token.start === "number" ? token.start : 1 + const nestedIndent = 2 + + for (let itemIndex = 0; itemIndex < token.items.length; itemIndex++) { + const item = token.items[itemIndex] + const itemId = `${id}-item-${itemIndex}` + const itemBox = new BoxRenderable(this.ctx, { + id: `${itemId}-box`, + width: "100%", + flexDirection: "column", + }) + + const markerText = token.ordered ? `${index++}. ` : "- " + const lineChunks: TextChunk[] = [this.createChunk(markerText, "markup.list")] + + if (item.task) { + const checked = "checked" in item ? Boolean(item.checked) : false + lineChunks.push(this.createChunk(`${checked ? "[x]" : "[ ]"} `, "markup.list")) + } + + let inlineRendered = false + const remainingTokens: MarkedToken[] = [] + + for (const child of item.tokens) { + if (child.type === "checkbox") { + continue + } + + if (child.type === "space") { + continue + } + + if (!inlineRendered && child.type === "text" && "tokens" in child && child.tokens) { + this.renderInlineContent(child.tokens, lineChunks) + inlineRendered = true + continue + } + + if (!inlineRendered && child.type === "paragraph") { + this.renderInlineContent((child as Tokens.Paragraph).tokens, lineChunks) + inlineRendered = true + continue + } + + remainingTokens.push(child as MarkedToken) + } + + const lineRenderable = new TextRenderable(this.ctx, { + id: `${itemId}-line`, + content: new StyledText(lineChunks), + width: "100%", + }) + itemBox.add(lineRenderable) + + let childIndex = 0 + for (const child of remainingTokens) { + const childId = `${itemId}-child-${childIndex}` + childIndex++ + const childRenderable = this.createBlockRenderable(child as MarkedToken, childId, 0) + if (childRenderable) { + childRenderable.marginLeft = nestedIndent + itemBox.add(childRenderable) + } + } + + listBox.add(itemBox) + } + + return listBox + } + /** * Update an existing table renderable in-place for style/conceal changes. * Much faster than rebuilding the entire table structure. @@ -427,7 +492,7 @@ export class MarkdownRenderable extends Renderable { const borderColor = this.getStyle("conceal")?.fg ?? "#888888" const headingStyle = this.getStyle("markup.heading") || this.getStyle("default") - const rowsToRender = this._streaming && table.rows.length > 0 ? table.rows.slice(0, -1) : table.rows + const rowsToRender = this.getTableRowsToRender(table) const colCount = table.header.length // Traverse existing table structure: tableBox -> columnBoxes -> cells @@ -501,8 +566,7 @@ export class MarkdownRenderable extends Renderable { private createTableRenderable(table: Tokens.Table, id: string, marginBottom: number = 0): Renderable { const colCount = table.header.length - // During streaming, skip the last row (might be incomplete) - const rowsToRender = this._streaming && table.rows.length > 0 ? table.rows.slice(0, -1) : table.rows + const rowsToRender = this.getTableRowsToRender(table) if (colCount === 0 || rowsToRender.length === 0) { return this.createTextRenderable([this.createDefaultChunk(table.raw)], id, marginBottom) @@ -614,32 +678,105 @@ export class MarkdownRenderable extends Renderable { return tableBox } - private createDefaultRenderable(token: MarkedToken, index: number, hasNextToken: boolean = false): Renderable | null { - const id = `${this.id}-block-${index}` - const marginBottom = hasNextToken ? 1 : 0 + private getBlockMarginBottom(hasNextToken: boolean): number { + return hasNextToken ? 1 : 0 + } - if (token.type === "code") { - return this.createCodeRenderable(token, id, marginBottom) + private getTableRowsToRender(table: Tokens.Table): Tokens.Table["rows"] { + if (!this._streaming) { + return table.rows + } + if (table.rows.length === 0) { + return table.rows + } + if (table.raw.endsWith("\n")) { + return table.rows } + return table.rows.slice(0, -1) + } - if (token.type === "table") { - return this.createTableRenderable(token, id, marginBottom) + private getTableRenderableRowCount(table: Tokens.Table): number { + return this.getTableRowsToRender(table).length + } + + private createBlockquoteRenderable(token: Tokens.Blockquote, id: string, marginBottom: number): Renderable { + const borderChars = { + topLeft: "", + topRight: "", + bottomLeft: "", + bottomRight: "", + horizontal: "", + vertical: ">", + topT: "", + bottomT: "", + leftT: "", + rightT: "", + cross: "", } + const borderColor = this.getStyle("punctuation.special")?.fg - if (token.type === "space") { - return null + const box = new BoxRenderable(this.ctx, { + id, + width: "100%", + flexDirection: "column", + marginBottom, + border: ["left"], + customBorderChars: borderChars, + borderColor: borderColor ?? "#FFFFFF", + paddingLeft: 1, + }) + + this.addBlockquoteChildren(box, token, id) + + return box + } + + private addBlockquoteChildren(box: BoxRenderable, token: Tokens.Blockquote, id: string): void { + const childTokens = token.tokens as MarkedToken[] + for (let i = 0; i < childTokens.length; i++) { + const child = childTokens[i] + const childId = `${id}-child-${i}` + const childRenderable = this.createBlockRenderable(child, childId, 0) + if (childRenderable) { + box.add(childRenderable) + } } + } - const chunks = this.renderTokenToChunks(token) - if (chunks.length === 0) { + private updateBlockquoteRenderable( + box: BoxRenderable, + token: Tokens.Blockquote, + id: string, + marginBottom: number, + ): void { + box.marginBottom = marginBottom + const borderColor = this.getStyle("punctuation.special")?.fg + if (borderColor) { + box.borderColor = borderColor + } + + const children = box.getChildren() as Renderable[] + for (const child of children) { + box.remove(child.id) + } + + this.addBlockquoteChildren(box, token, id) + } + + private createDefaultRenderable(token: MarkedToken, index: number, hasNextToken: boolean = false): Renderable | null { + const id = `${this.id}-block-${index}` + const marginBottom = this.getBlockMarginBottom(hasNextToken) + + const renderable = this.createBlockRenderable(token, id, marginBottom) + if (!renderable || token.type === "space") { return null } - return this.createTextRenderable(chunks, id, marginBottom) + return renderable } private updateBlockRenderable(state: BlockState, token: MarkedToken, index: number, hasNextToken: boolean): void { - const marginBottom = hasNextToken ? 1 : 0 + const marginBottom = this.getBlockMarginBottom(hasNextToken) if (token.type === "code") { const codeRenderable = state.renderable as CodeRenderable @@ -658,8 +795,8 @@ export class MarkdownRenderable extends Renderable { // During streaming, only rebuild when complete row count changes (skip incomplete last row) if (this._streaming) { - const prevCompleteRows = Math.max(0, prevTable.rows.length - 1) - const newCompleteRows = Math.max(0, newTable.rows.length - 1) + const prevCompleteRows = this.getTableRenderableRowCount(prevTable) + const newCompleteRows = this.getTableRenderableRowCount(newTable) // Check if both previous and new are in raw fallback mode (no complete rows to render) const prevIsRawFallback = prevTable.header.length === 0 || prevCompleteRows === 0 @@ -683,7 +820,30 @@ export class MarkdownRenderable extends Renderable { return } - // Text-based renderables (paragraph, heading, list, blockquote, hr) + if (token.type === "list") { + const nextRenderable = this._blockStates[index + 1]?.renderable + this.remove(state.renderable.id) + const newRenderable = this.createListRenderable(token as Tokens.List, `${this.id}-block-${index}`, marginBottom) + if (nextRenderable) { + this.insertBefore(newRenderable, nextRenderable) + } else { + this.add(newRenderable) + } + state.renderable = newRenderable + return + } + + if (token.type === "blockquote") { + this.updateBlockquoteRenderable( + state.renderable as BoxRenderable, + token as Tokens.Blockquote, + `${this.id}-block-${index}`, + marginBottom, + ) + return + } + + // Text-based renderables (paragraph, heading, list, hr) const textRenderable = state.renderable as TextRenderable const chunks = this.renderTokenToChunks(token) textRenderable.content = new StyledText(chunks) @@ -736,9 +896,11 @@ export class MarkdownRenderable extends Renderable { const { token } = blockTokens[i] const hasNextToken = i < lastBlockIndex const existing = this._blockStates[blockIndex] + const marginBottom = this.getBlockMarginBottom(hasNextToken) // Same token object reference means unchanged if (existing && existing.token === token) { + existing.renderable.marginBottom = marginBottom blockIndex++ continue } @@ -746,6 +908,7 @@ export class MarkdownRenderable extends Renderable { // Same content, update reference if (existing && existing.tokenRaw === token.raw && existing.token.type === token.type) { existing.token = token + existing.renderable.marginBottom = marginBottom blockIndex++ continue } @@ -825,12 +988,42 @@ export class MarkdownRenderable extends Renderable { // Tables - update in place for better performance const marginBottom = hasNextToken ? 1 : 0 this.updateTableRenderable(state.renderable, state.token as Tokens.Table, marginBottom) + } else if (state.token.type === "list") { + const nextRenderable = this._blockStates[i + 1]?.renderable + this.remove(state.renderable.id) + const newRenderable = this.createListRenderable( + state.token as Tokens.List, + `${this.id}-block-${i}`, + this.getBlockMarginBottom(hasNextToken), + ) + if (nextRenderable) { + this.insertBefore(newRenderable, nextRenderable) + } else { + this.add(newRenderable) + } + state.renderable = newRenderable } else { // TextRenderable blocks - regenerate chunks with new style/conceal - const textRenderable = state.renderable as TextRenderable - const chunks = this.renderTokenToChunks(state.token) - if (chunks.length > 0) { - textRenderable.content = new StyledText(chunks) + if (state.token.type === "blockquote") { + const nextRenderable = this._blockStates[i + 1]?.renderable + this.remove(state.renderable.id) + const newRenderable = this.createBlockquoteRenderable( + state.token as Tokens.Blockquote, + `${this.id}-block-${i}`, + this.getBlockMarginBottom(hasNextToken), + ) + if (nextRenderable) { + this.insertBefore(newRenderable, nextRenderable) + } else { + this.add(newRenderable) + } + state.renderable = newRenderable + } else { + const textRenderable = state.renderable as TextRenderable + const chunks = this.renderTokenToChunks(state.token) + if (chunks.length > 0) { + textRenderable.content = new StyledText(chunks) + } } } } diff --git a/packages/core/src/renderables/__tests__/Markdown.test.ts b/packages/core/src/renderables/__tests__/Markdown.test.ts index c0e40246f..9d9ae1080 100644 --- a/packages/core/src/renderables/__tests__/Markdown.test.ts +++ b/packages/core/src/renderables/__tests__/Markdown.test.ts @@ -673,6 +673,39 @@ test("list with inline formatting", async () => { `) }) +test("nested unordered list", async () => { + const markdown = `- Item 1 + - Nested A + - Nested B +- Item 2` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + - Item 1 + - Nested A + - Nested B + - Item 2" + `) +}) + +test("list item with fenced code block", async () => { + const markdown = `- Item with code: + + \`\`\`ts + const value = 1 + console.log(value) + \`\`\` +- Next item` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + - Item with code: + const value = 1 + console.log(value) + - Next item" + `) +}) + // Blockquote tests test("simple blockquote", async () => { @@ -682,7 +715,7 @@ test("simple blockquote", async () => { expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` " > This is a quote - spanning multiple lines" + > spanning multiple lines" `) }) @@ -814,7 +847,6 @@ Visit [GitHub](https://github.com) for more. - inline code support - Italic and bold text - Code Example const md = new MarkdownRenderable(ctx, { @@ -831,6 +863,203 @@ Visit [GitHub](https://github.com) for more. `) }) +test("llm-style response with quotes, nested lists, hr, and paragraphs", async () => { + const markdown = `Here is the plan: + +> We will ship in two phases. +> Phase one focuses on stability. + +- Top item + - Nested one + - Nested two +- Second item + +--- + +Final paragraph with closing remarks.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Here is the plan: + + > We will ship in two phases. + > Phase one focuses on stability. + + - Top item + - Nested one + - Nested two + - Second item + + --- + + Final paragraph with closing remarks." + `) +}) + +test("complex markdown with mixed nodes and nesting", async () => { + const markdown = `# Weekly Update + +Quick summary for the team with **bold**, *italic*, \`code\`, and ~~strikethrough~~. + +## People + +- Alice + - Role: Tech Lead + - Profile: https://example.com/alice + - Notes: [handoff doc](https://example.com/alice/handoff) +- Bob + - Role: Infra + - Profile: https://example.com/bob + - Notes: [runbook](https://example.com/runbook) + +> Status call highlights: +> - On-call load is high +> - Add more automation +> - Improve alert routing +> - Task list: +> - [ ] Triage paging rules +> - [x] Reduce noisy alerts +> +> 1. Investigate spikes +> 1. Check dashboard +> 2. Review traces +> 2. Apply fixes +> +> Final note with **bold** and \`code\`. + +## Metrics + +Table below: +| Metric | Value | +|---|---| +| Coverage | 92% | +| Latency | 120ms | + +Links right after table: +- Dashboard: https://example.com/metrics +- Incident history: https://example.com/incidents + +\`\`\`ts +export const flag = true +\`\`\` + +--- + +### Risks + +Paragraph close to the table and hr with a link to [docs](https://example.com/docs). + +> Second quote block +> with multiple lines +> and a list: +> - Q item 1 +> - Q item 2 +> - Q nested 1 +> - Q nested 2 +> +> Final quoted line. + +Final paragraph with an image ![alt](https://example.com/img.png).` + + const { + renderer: localRenderer, + renderOnce: localRenderOnce, + captureCharFrame, + } = await createTestRenderer({ + width: 90, + height: 120, + }) + + try { + const md = new MarkdownRenderable(localRenderer, { + id: "markdown", + content: markdown, + syntaxStyle, + }) + + localRenderer.root.add(md) + await localRenderOnce() + + const lines = captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + lines).toMatchInlineSnapshot(` + " + Weekly Update + + Quick summary for the team with bold, italic, code, and strikethrough. + + People + + - Alice + - Role: Tech Lead + - Profile: https://example.com/alice (https://example.com/alice) + - Notes: handoff doc (https://example.com/alice/handoff) + - Bob + - Role: Infra + - Profile: https://example.com/bob (https://example.com/bob) + - Notes: runbook (https://example.com/runbook) + + > Status call highlights: + > - On-call load is high + > - Add more automation + > - Improve alert routing + > - Task list: + > - [ ] Triage paging rules + > - [x] Reduce noisy alerts + > + > 1. Investigate spikes + > 1. Check dashboard + > 2. Review traces + > 2. Apply fixes + > + > Final note with bold and code. + + Metrics + + Table below: + + ┌──────────┬───────┐ + │Metric │Value │ + │──────────│───────│ + │Coverage │92% │ + │──────────│───────│ + │Latency │120ms │ + └──────────┴───────┘ + + Links right after table: + + - Dashboard: https://example.com/metrics (https://example.com/metrics) + - Incident history: https://example.com/incidents (https://example.com/incidents) + + export const flag = true + + --- + + Risks + + Paragraph close to the table and hr with a link to docs (https://example.com/docs). + + > Second quote block + > with multiple lines + > and a list: + > - Q item 1 + > - Q item 2 + > - Q nested 1 + > - Q nested 2 + > + > Final quoted line. + + Final paragraph with an image alt." + `) + } finally { + localRenderer.destroy() + } +}) + // Custom renderNode tests test("custom renderNode can override heading rendering", async () => { @@ -1178,6 +1407,59 @@ test("streaming mode keeps trailing tokens unstable", async () => { expect(frame2).toContain("Hello World") }) +test("streaming task list keeps checkbox and text on same line", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "- [ ]", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + md.content = "- [ ] todo" + await renderOnce() + + const frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + frame).toMatchInlineSnapshot(` + " + - [ ] todo" + `) +}) + +test("streaming blockquote keeps single prefix per line", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "> first line", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + md.content = "> first line\n> second line" + await renderOnce() + + const frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + frame).toMatchInlineSnapshot(` + " + > first line + > second line" + `) +}) + test("non-streaming mode parses all tokens as stable", async () => { const md = new MarkdownRenderable(renderer, { id: "markdown", @@ -1522,6 +1804,34 @@ test("streaming table transitions cleanly from raw fallback to proper table", as expect(frame).not.toContain("| D") }) +test("streaming table renders with conceal=false when row is complete", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A | B |\n|---|---|\n| 1 | 2 |\n", + syntaxStyle, + conceal: false, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + const frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + frame).toMatchInlineSnapshot(` + " + ┌───┬───┐ + │A │B │ + │───│───│ + │1 │2 │ + └───┴───┘" + `) +}) + test("streaming table can transition back to raw fallback when rows are removed", async () => { const md = new MarkdownRenderable(renderer, { id: "markdown", @@ -1706,7 +2016,6 @@ The table alignment uses: // Switch theme md.syntaxStyle = theme2 await renderOnce() - const frame2 = captureSpans() const headingSpan2 = findSpanContaining(frame2, "OpenTUI Markdown Demo") expect(headingSpan2).toBeDefined() diff --git a/packages/core/src/testing/test-recorder.test.ts b/packages/core/src/testing/test-recorder.test.ts index 483e4c21b..7ad8cba5a 100644 --- a/packages/core/src/testing/test-recorder.test.ts +++ b/packages/core/src/testing/test-recorder.test.ts @@ -217,8 +217,9 @@ describe("TestRecorder", () => { await renderOnce() const frames = recorder.recordedFrames - expect(frames.length).toBe(2) - expect(frames[1].timestamp).toBeGreaterThan(frames[0].timestamp) + expect(frames.length).toBeGreaterThanOrEqual(2) + const lastIndex = frames.length - 1 + expect(frames[lastIndex].timestamp).toBeGreaterThan(frames[lastIndex - 1].timestamp) recorder.stop() })