kde/gui: render markdown tables in chat view

Add table rendering support to ChatBlockModel:
- isTableLine(): detect pipe-delimited table rows
- renderTable(): convert table lines to HTML <table>
- renderProse(): split prose into text/table segments
- incrementalAppend: handle table state transitions during streaming

Tables render with header row detection (before |---|---| separator)
and proper incremental updates as content streams in.
This commit is contained in:
Levi Neely 2026-09-04 13:28:06 +02:00
parent 065e399444
commit 5f68871c65
2 changed files with 159 additions and 17 deletions

View File

@ -319,6 +319,86 @@ QString ChatBlockModel::escapeForHtml(const QString &text) const
return result;
}
bool ChatBlockModel::isTableLine(const QString &line)
{
const QString trimmed = line.trimmed();
return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.count('|') >= 2;
}
QString ChatBlockModel::renderTable(const QStringList &tableLines) const
{
const QString defaultColor = getDefaultTextColor();
QString html = QStringLiteral("<table border=\"1\" cellspacing=\"0\" cellpadding=\"3\" style=\"color:")
+ defaultColor + QStringLiteral(";\">");
bool pastSeparator = false;
for (const QString &line : tableLines) {
const QString trimmed = line.trimmed();
// Skip separator rows (|---|---|)
static QRegularExpression sepRe(QStringLiteral(R"(^\|[\s:|-]+\|$)"));
if (sepRe.match(trimmed).hasMatch()) {
pastSeparator = true;
continue;
}
// Strip leading/trailing |, split by |
QString inner = trimmed;
if (inner.startsWith('|')) inner = inner.mid(1);
if (inner.endsWith('|')) inner.chop(1);
const QStringList cells = inner.split('|');
const QString tag = pastSeparator ? QStringLiteral("td") : QStringLiteral("th");
html += QStringLiteral("<tr>");
for (const QString &cell : cells) {
html += QStringLiteral("<") + tag + QStringLiteral(">")
+ cell.trimmed().toHtmlEscaped()
+ QStringLiteral("</") + tag + QStringLiteral(">");
}
html += QStringLiteral("</tr>");
}
html += QStringLiteral("</table>");
return html;
}
QString ChatBlockModel::renderProse(const QStringList &lines) const
{
QString html;
QStringList textRun;
QStringList tableRun;
auto flushText = [&]() {
if (!textRun.isEmpty()) {
html += escapeForHtml(textRun.join('\n'));
textRun.clear();
}
};
auto flushTable = [&]() {
if (!tableRun.isEmpty()) {
html += renderTable(tableRun);
tableRun.clear();
}
};
for (const QString &line : lines) {
if (isTableLine(line)) {
flushText();
tableRun.append(line);
} else {
flushTable();
textRun.append(line);
}
}
flushText();
flushTable();
return html;
}
void ChatBlockModel::rebuildBlockHtml(int blockIndex)
{
if (blockIndex < 0 || blockIndex >= m_blocks.size())
@ -337,7 +417,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
auto flushProse = [&]() {
if (!proseLines.isEmpty()) {
QString proseHtml = escapeForHtml(proseLines.join('\n'));
QString proseHtml = renderProse(proseLines);
html += proseHtml;
BlockSegment seg;
@ -460,6 +540,13 @@ void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
// Check for fence open
auto match = s_fenceOpenRe.match(line);
if (match.hasMatch()) {
// Flush any accumulated table into a finalized segment
if (rs.inTable && !rs.tableLines.isEmpty()) {
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(rs.tableLines);
rs.tableLines.clear();
rs.inTable = false;
}
// Flush any accumulated prose into a finalized text segment
if (!rs.proseLines.isEmpty()) {
// The last segment should already be Text with these prose lines;
@ -479,6 +566,37 @@ void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
return;
}
if (isTableLine(line)) {
// Transitioning from prose to table — flush prose, start table segment
if (!rs.inTable) {
if (!rs.proseLines.isEmpty())
rs.proseLines.clear();
rs.inTable = true;
rs.tableLines.clear();
rs.tableLines.append(line);
// Start a new segment for the table
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = renderTable(rs.tableLines);
block.segments.append(seg);
} else {
// Continue accumulating table lines
rs.tableLines.append(line);
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(rs.tableLines);
}
rebuildRenderedHtmlFromSegments(block);
return;
}
// Non-table prose line
if (rs.inTable) {
// Table just ended — finalize the table segment, start new prose
rs.tableLines.clear();
rs.inTable = false;
}
// Normal prose line — extend or create last text segment
rs.proseLines.append(line);
@ -635,25 +753,44 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par
+ inner + QStringLiteral("</pre>");
}
} else {
// Show partial line appended to current prose — just escape one line
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
// Temporarily append to the segment (caller will restore)
QString &segHtml = block.segments.last().html;
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
// Show partial line appended to current prose/table
if (rs.inTable) {
// Currently building a table — show partial line as part of the table
if (isTableLine(partialLine)) {
QStringList preview = rs.tableLines;
preview.append(partialLine);
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(preview);
} else {
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
// Partial line breaks the table — just show it as escaped text after
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
}
} else {
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
// Temporarily append to the segment (caller will restore)
QString &segHtml = block.segments.last().html;
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
} else {
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
}
} else {
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
}
}
}
rebuildRenderedHtmlFromSegments(block);

View File

@ -37,6 +37,8 @@ struct BlockRenderState {
QString fenceLang; // Language of the open fence
QStringList fenceLines; // Accumulated lines in the open fence
QStringList proseLines; // Accumulated prose lines (not yet flushed)
QStringList tableLines; // Accumulated table lines (being built)
bool inTable = false; // Currently accumulating table lines
int committedLineCount = 0; // Number of content lines already processed incrementally
// Incremental syntax highlighting state for open code fences
KSyntaxHighlighting::State hlState; // Highlighter state after last processed line
@ -113,6 +115,9 @@ private:
QString highlightCode(const QString &code, const QString &language) const;
QString highlightCodeWrapped(const QString &innerHtml) const;
QString escapeForHtml(const QString &text) const;
QString renderProse(const QStringList &lines) const;
QString renderTable(const QStringList &tableLines) const;
static bool isTableLine(const QString &line);
QString getDefaultTextColor() const;
// Diagram rendering