Skip to content

Commit d873a03

Browse files
feat(markdown): share one renderer for chat and preview
Chat transcript and file Markdown preview used separate pipelines, so math, code chrome, fonts, and allowlisted HTML only worked in one place. Route both through MarkdownView: preprocess TeX/HTML into safe markdown, render with Qt NoHTML, paint formulas via a cached layout engine, and cap wide/large previews so the UI stays readable and responsive.
1 parent 1d9bd71 commit d873a03

39 files changed

Lines changed: 7646 additions & 1573 deletions

‎src/AcpTurnFolding.cpp‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ QVector<Turn> compute(const QVector<AcpTimelineEntry> &timeline,
7070
starts.append(n);
7171
const int turnCount = starts.size() - 1;
7272

73-
// The newest turn stays open for review; only older ones can fold, unless
74-
// the caller wants it too (to give it an expanded "Worked for Xs" row).
73+
// The newest turn may still be running; only older ones can fold, unless
74+
// the caller says it has ended.
7575
const int lastTurn = includeNewest ? turnCount : turnCount - 1;
7676
for (int t = 0; t < lastTurn; ++t) {
7777
Turn turn;
@@ -98,25 +98,40 @@ QVector<Turn> compute(const QVector<AcpTimelineEntry> &timeline,
9898
continue;
9999
const AcpTimelineEntry &e = timeline.at(i);
100100
if (e.kind == AcpTimelineEntry::Kind::ToolCall) {
101-
if (toolCalls.contains(e.toolCallId))
101+
if (toolCalls.contains(e.toolCallId)) {
102102
turn.folded.append(i);
103+
++turn.toolCalls;
104+
}
103105
} else if (e.messageIndex >= 0 && e.messageIndex < messages.size()
104106
&& messageHasVisibleContent(messages.at(e.messageIndex))) {
105107
turn.folded.append(i);
106108
}
107109
}
108-
if (turn.folded.isEmpty())
109-
continue;
110-
111110
if (turn.anchor >= 0 && turn.survivor >= 0) {
112111
const qint64 a = messages.at(timeline.at(turn.anchor).messageIndex).timestamp;
113112
const qint64 b = messages.at(timeline.at(turn.survivor).messageIndex).timestamp;
114113
if (a > 0 && b >= a)
115114
turn.workedMs = b - a;
116115
}
116+
// A turn with nothing to fold still gets its "Worked for Xs" label
117+
// (report-only reply), as long as there is a duration to show.
118+
if (turn.folded.isEmpty() && turn.workedMs < 0)
119+
continue;
117120
out.append(turn);
118121
}
119122
return out;
120123
}
121124

125+
int newestTurnBegin(const QVector<AcpTimelineEntry> &timeline,
126+
const QVector<AcpMessage> &messages)
127+
{
128+
if (timeline.isEmpty())
129+
return -1;
130+
for (int i = timeline.size() - 1; i > 0; --i) {
131+
if (isUserAnchor(timeline.at(i), messages))
132+
return i;
133+
}
134+
return 0;
135+
}
136+
122137
} // namespace AcpTurnFolding

‎src/AcpTurnFolding.h‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@
3636
// - the survivor is the LAST assistant message with visible content — the
3737
// report the user reads; it stays visible beside the fold row
3838
// - every other entry that would render (tool calls, thoughts, earlier
39-
// assistant messages) is folded
40-
// The LAST turn is never folded: the user reviews it. Once it ends the view
41-
// shows its "Worked for Xs" row expanded; it folds when a newer prompt starts
42-
// a new turn.
39+
// assistant messages) is folded; a turn may fold nothing (report only)
40+
// While the newest turn runs it is not folded (the view shows a live ticking
41+
// "Worked for Xs" row above it); once it ends it folds exactly like every
42+
// older turn.
4343
//
4444
// Complexity: O(n) time over the timeline, one pass.
4545
namespace AcpTurnFolding {
@@ -51,18 +51,25 @@ struct Turn
5151
int anchor = -1; // timeline index of the user/goal message; -1 for a prefix turn
5252
int survivor = -1; // timeline index of the final report; -1 when there is none
5353
QVector<int> folded; // timeline indices hidden while collapsed, in order
54+
int toolCalls = 0; // tool-call entries among `folded`
5455
qint64 workedMs = -1; // anchor -> survivor wall time; -1 when unknown
5556
};
5657

5758
bool messageHasVisibleContent(const AcpMessage &msg);
5859

5960
// Every foldable turn except the newest turn (included when includeNewest is
60-
// set; the view then shows its row expanded). Turns with nothing to fold are omitted.
61+
// set, i.e. the newest turn has ended). A turn with nothing to fold is kept only
62+
// when its duration is known (the row is then a plain, non-expandable label).
6163
QVector<Turn> compute(const QVector<AcpTimelineEntry> &timeline,
6264
const QVector<AcpMessage> &messages,
6365
const QHash<QString, AcpProtocol::AcpToolCall> &toolCalls,
6466
bool includeNewest = false);
6567

68+
// First timeline index of the newest turn (last user/goal prompt, else 0);
69+
// -1 for an empty timeline.
70+
int newestTurnBegin(const QVector<AcpTimelineEntry> &timeline,
71+
const QVector<AcpMessage> &messages);
72+
6673
} // namespace AcpTurnFolding
6774

6875
#endif // ACP_TURN_FOLDING_H

‎src/CMakeLists.txt‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ qt_add_executable(NotepadAI
1414
AcpSessionModel.h
1515
AcpTranscriptTruncation.h
1616
AcpTurnFolding.h
17+
markdown/MarkdownPreprocessor.h
18+
markdown/MathLayout.h
19+
markdown/MathParser.h
20+
markdown/MathRenderCache.h
1721
ActionUtils.h
1822
ApplicationSettings.h
1923
CronExpression.h
@@ -69,8 +73,8 @@ qt_add_executable(NotepadAI
6973
CsvSortFilter.h
7074
CsvFilterStrip.h
7175
IFaceTable.h
76+
MarkdownPreviewSupport.h
7277
MarkdownPreviewWidget.h
73-
MarkdownRenderer.h
7478
PandocExporter.h
7579
PreviewContentWidget.h
7680
PreviewTabManager.h
@@ -130,6 +134,10 @@ qt_add_executable(NotepadAI
130134
AcpSessionModel.cpp
131135
AcpTranscriptTruncation.cpp
132136
AcpTurnFolding.cpp
137+
markdown/MarkdownPreprocessor.cpp
138+
markdown/MathLayout.cpp
139+
markdown/MathParser.cpp
140+
markdown/MathRenderCache.cpp
133141
LocalAcpProcessChannel.cpp
134142
SshAcpProcessChannel.cpp
135143
CronExpression.cpp
@@ -178,7 +186,6 @@ qt_add_executable(NotepadAI
178186
CsvFilterStrip.cpp
179187
IFaceTable.cpp
180188
MarkdownPreviewWidget.cpp
181-
MarkdownRenderer.cpp
182189
PandocExporter.cpp
183190
PreviewTabManager.cpp
184191
IFaceTableMixer.cpp
@@ -451,7 +458,10 @@ qt_add_executable(NotepadAI
451458
widgets/AcpImageAttachmentList.cpp
452459
widgets/AcpImageAttachmentList.h
453460
widgets/AcpMessageWidget.cpp
461+
widgets/MarkdownView.cpp
454462
widgets/AcpMessageWidget.h
463+
widgets/ChatFont.h
464+
widgets/MarkdownView.h
455465
widgets/AcpPermissionPrompt.cpp
456466
widgets/AcpPermissionPrompt.h
457467
widgets/AcpPlanWidget.cpp

‎src/MarkdownPreviewSupport.h‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#ifndef MARKDOWN_PREVIEW_SUPPORT_H
20+
#define MARKDOWN_PREVIEW_SUPPORT_H
21+
22+
#include <QString>
23+
24+
// The two pure pieces of MarkdownPreviewWidget, kept free of the application so
25+
// they can be unit-tested.
26+
namespace MarkdownPreviewSupport {
27+
28+
// Cap a document before it reaches the synchronous renderer. Over the cap the text
29+
// is cut at the last line boundary (unless that would discard more than half the
30+
// budget, then at the cap itself) and a truncation notice is appended.
31+
inline QString clampSource(const QString &text, int maxChars)
32+
{
33+
if (text.size() <= maxChars)
34+
return text;
35+
int cut = int(text.lastIndexOf(QLatin1Char('\n'), maxChars));
36+
if (cut < maxChars / 2)
37+
cut = maxChars;
38+
return text.left(cut)
39+
+ QStringLiteral("\n\n---\n\n*[Preview truncated — file too large]*\n");
40+
}
41+
42+
// Where in the rendered document a source line lands, as a 0..1 fraction of the
43+
// scroll range: (line - 1) / (totalLines - 1), clamped. 0 for degenerate input.
44+
inline double scrollFraction(int line, int totalLines)
45+
{
46+
if (totalLines <= 1)
47+
return 0.0;
48+
const double f = double(line - 1) / double(totalLines - 1);
49+
return f < 0.0 ? 0.0 : (f > 1.0 ? 1.0 : f);
50+
}
51+
52+
} // namespace MarkdownPreviewSupport
53+
54+
#endif // MARKDOWN_PREVIEW_SUPPORT_H

0 commit comments

Comments
 (0)