Skip to content

Commit 61ba7bc

Browse files
authored
Merge pull request #600 from creatang/main
feat(tui): 优化 Markdown 渲染与折叠性能,并新增 /web 启动入口
2 parents c435f78 + 9a432ef commit 61ba7bc

13 files changed

Lines changed: 748 additions & 164 deletions

internal/tui/core/app/app.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ type appRuntimeState struct {
163163
logPersistDirty bool
164164
logPersistVersion int
165165
transcriptContent string
166+
transcriptBlockRenderCache map[string]string
166167
transcriptProcessFoldAvailable bool
167168
transcriptProcessExpanded bool
168169
transcriptProcessExpandedOrdinal int
@@ -395,12 +396,13 @@ func newApp(container tuibootstrap.Container) (App, error) {
395396
markdownRenderer: markdownRenderer,
396397
},
397398
appRuntimeState: appRuntimeState{
398-
nowFn: time.Now,
399-
focus: panelInput,
400-
todoFilter: todoFilterAll,
401-
layoutCached: true,
402-
cachedWidth: 128,
403-
cachedHeight: 40,
399+
nowFn: time.Now,
400+
focus: panelInput,
401+
todoFilter: todoFilterAll,
402+
layoutCached: true,
403+
cachedWidth: 128,
404+
cachedHeight: 40,
405+
transcriptBlockRenderCache: make(map[string]string),
404406
// 初始进入草稿态时锁定启动页,直到发送或切换 session 才退出。
405407
startupScreenLocked: true,
406408
},

internal/tui/core/app/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const (
3636
slashCommandSkills = "/skills"
3737
slashCommandSkill = "/skill"
3838
slashCommandCheckpoint = "/checkpoint"
39+
slashCommandWeb = "/web"
3940

4041
slashUsageHelp = "/help"
4142
slashUsageExit = "/exit"
@@ -56,6 +57,7 @@ const (
5657
slashUsageCheckpointRestore = "/checkpoint restore <id>"
5758
slashUsageCheckpointUndo = "/checkpoint undo"
5859
slashUsageCheckpointDiff = "/checkpoint diff <id>"
60+
slashUsageWeb = "/web"
5961

6062
commandMenuTitle = "Suggestions"
6163
providerPickerTitle = "Select Provider"
@@ -158,6 +160,7 @@ var builtinSlashCommands = []slashCommand{
158160
{Usage: slashUsageProviderAdd, Description: "Add a new custom provider"},
159161
{Usage: slashUsageModel, Description: "Open the interactive model picker"},
160162
{Usage: slashUsageSession, Description: "Switch to another session"},
163+
{Usage: slashUsageWeb, Description: "Start Web UI in browser"},
161164
{Usage: slashUsageExit, Description: "Exit NeoCode"},
162165
}
163166

internal/tui/core/app/commands_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func TestBuiltinSlashCommands(t *testing.T) {
2323
foundSkills := false
2424
foundSkillUse := false
2525
foundCheckpoint := false
26+
foundWeb := false
2627
foundStatus := false
2728
for _, cmd := range builtinSlashCommands {
2829
if cmd.Usage == slashUsageHelp {
@@ -40,6 +41,9 @@ func TestBuiltinSlashCommands(t *testing.T) {
4041
if cmd.Usage == slashUsageCheckpoint {
4142
foundCheckpoint = true
4243
}
44+
if cmd.Usage == slashUsageWeb {
45+
foundWeb = true
46+
}
4347
if strings.EqualFold(cmd.Usage, "/status") {
4448
foundStatus = true
4549
}
@@ -59,6 +63,9 @@ func TestBuiltinSlashCommands(t *testing.T) {
5963
if !foundCheckpoint {
6064
t.Error("expected to find /checkpoint command")
6165
}
66+
if !foundWeb {
67+
t.Error("expected to find /web command")
68+
}
6269
if foundStatus {
6370
t.Error("did not expect /status command in builtin slash commands")
6471
}

internal/tui/core/app/markdown_renderer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import tuiinfra "neo-code/internal/tui/infra"
44

55
const (
66
defaultMarkdownStyle = "dark"
7-
defaultMarkdownCacheMax = 128
7+
defaultMarkdownCacheMax = 512
88
)
99

1010
type markdownContentRenderer interface {

internal/tui/core/app/styles.go

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"strings"
55

66
"github.com/charmbracelet/lipgloss"
7+
"github.com/charmbracelet/x/ansi"
78
)
89

910
const (
@@ -171,10 +172,12 @@ func newStyles() styles {
171172
messageAgentTag: tagStyle(neoBadge),
172173
messageBody: lipgloss.NewStyle().
173174
Foreground(lipgloss.Color(neoText)).
174-
MarginLeft(3),
175+
MarginLeft(2).
176+
PaddingLeft(1),
175177
messageUserBody: lipgloss.NewStyle().
176178
Foreground(lipgloss.Color(youText)).
177-
MarginLeft(3),
179+
MarginLeft(2).
180+
PaddingLeft(1),
178181
inlineNotice: lipgloss.NewStyle().
179182
Foreground(lipgloss.Color(oliveGray)).
180183
Italic(true),
@@ -294,45 +297,14 @@ func wrapPlain(text string, width int) string {
294297
if width <= 0 {
295298
return text
296299
}
297-
298-
lines := strings.Split(text, "\n")
299-
out := make([]string, 0, len(lines))
300-
for _, line := range lines {
301-
runes := []rune(line)
302-
if len(runes) == 0 {
303-
out = append(out, "")
304-
continue
305-
}
306-
for len(runes) > width {
307-
out = append(out, string(runes[:width]))
308-
runes = runes[width:]
309-
}
310-
out = append(out, string(runes))
311-
}
312-
return strings.Join(out, "\n")
300+
return wrapByDisplayWidth(text, width, false)
313301
}
314302

315303
func wrapCodeBlock(text string, width int) string {
316304
if width <= 0 {
317305
return text
318306
}
319-
320-
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
321-
out := make([]string, 0, len(lines))
322-
for _, line := range lines {
323-
expanded := strings.ReplaceAll(line, "\t", " ")
324-
runes := []rune(expanded)
325-
if len(runes) == 0 {
326-
out = append(out, "")
327-
continue
328-
}
329-
for len(runes) > width {
330-
out = append(out, string(runes[:width]))
331-
runes = runes[width:]
332-
}
333-
out = append(out, string(runes))
334-
}
335-
return strings.Join(out, "\n")
307+
return wrapByDisplayWidth(text, width, true)
336308
}
337309

338310
func preview(text string, width int, lines int) string {
@@ -351,9 +323,45 @@ func preview(text string, width int, lines int) string {
351323
return "(empty)"
352324
}
353325
joined := strings.Join(out, "\n")
354-
runes := []rune(joined)
355-
if len(runes) > width*lines {
356-
return string(runes[:width*lines-3]) + "..."
326+
maxWidth := width * lines
327+
if maxWidth <= 0 {
328+
return joined
329+
}
330+
if ansi.StringWidth(joined) > maxWidth {
331+
if maxWidth <= 3 {
332+
return ansi.Cut(joined, 0, maxWidth)
333+
}
334+
return ansi.Cut(joined, 0, maxWidth-3) + "..."
357335
}
358336
return joined
359337
}
338+
339+
func wrapByDisplayWidth(text string, width int, expandTabs bool) string {
340+
if width <= 0 {
341+
return text
342+
}
343+
344+
normalized := strings.ReplaceAll(text, "\r\n", "\n")
345+
lines := strings.Split(normalized, "\n")
346+
out := make([]string, 0, len(lines))
347+
for _, line := range lines {
348+
if expandTabs {
349+
line = strings.ReplaceAll(line, "\t", " ")
350+
}
351+
if line == "" {
352+
out = append(out, "")
353+
continue
354+
}
355+
remaining := line
356+
for ansi.StringWidth(remaining) > width {
357+
chunk := ansi.Cut(remaining, 0, width)
358+
if chunk == "" {
359+
break
360+
}
361+
out = append(out, chunk)
362+
remaining = ansi.Cut(remaining, width, ansi.StringWidth(remaining))
363+
}
364+
out = append(out, remaining)
365+
}
366+
return strings.Join(out, "\n")
367+
}

internal/tui/core/app/update.go

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const pasteSessionPerLineGuard = 8 * time.Millisecond
6969
const inlineLogMarker = "[[neo-log]] "
7070
const sessionWorkdirMissingWarning = "Session workspace not found, keeping current workspace."
7171
const localLogViewerPersistDir = "log-viewer"
72+
const transcriptBlockRenderCacheMax = 2048
7273

7374
type sessionLogPersistenceRuntime interface {
7475
LoadSessionLogEntries(ctx context.Context, sessionID string) ([]tuiservices.SessionLogEntry, error)
@@ -5591,20 +5592,63 @@ func (a *App) normalizeComposerHeight() {
55915592
}
55925593

55935594
func (a *App) rebuildTranscript() {
5595+
a.rebuildTranscriptInternal(false)
5596+
}
5597+
5598+
func (a *App) rebuildTranscriptForFoldToggle() {
5599+
a.rebuildTranscriptInternal(true)
5600+
}
5601+
5602+
func (a *App) rebuildTranscriptInternal(foldToggleOnly bool) {
55945603
width := max(24, a.transcript.Width)
55955604
if len(a.activeMessages) == 0 {
55965605
queued := a.renderQueuedInterventionBlock(width)
55975606
if strings.TrimSpace(queued) == "" {
55985607
a.setTranscriptContent(a.styles.empty.Width(width).Render(emptyConversationText))
55995608
a.transcript.GotoTop()
5609+
if foldToggleOnly {
5610+
a.transcriptScrollbarDrag = false
5611+
a.clearTextSelection()
5612+
}
56005613
return
56015614
}
56025615
a.setTranscriptContent(queued)
56035616
a.transcript.GotoTop()
5617+
if foldToggleOnly {
5618+
a.transcriptScrollbarDrag = false
5619+
a.clearTextSelection()
5620+
}
56045621
return
56055622
}
56065623

56075624
atBottom := a.transcript.AtBottom()
5625+
content, hasBlock := a.composeTranscriptContent(width)
5626+
if !hasBlock {
5627+
a.setTranscriptContent(a.styles.empty.Width(width).Render(emptyConversationText))
5628+
a.transcript.GotoTop()
5629+
if foldToggleOnly {
5630+
a.transcriptScrollbarDrag = false
5631+
a.clearTextSelection()
5632+
}
5633+
return
5634+
}
5635+
5636+
a.setTranscriptContent(content)
5637+
if atBottom {
5638+
a.transcript.GotoBottom()
5639+
}
5640+
5641+
if foldToggleOnly {
5642+
a.transcriptScrollbarDrag = false
5643+
a.clearTextSelection()
5644+
maxOffset := a.transcriptMaxOffset()
5645+
if a.transcript.YOffset > maxOffset {
5646+
a.transcript.SetYOffset(maxOffset)
5647+
}
5648+
}
5649+
}
5650+
5651+
func (a *App) composeTranscriptContent(width int) (string, bool) {
56085652
foldSegments := findTranscriptProcessFoldSegments(a.activeMessages)
56095653
foldExists := len(foldSegments) > 0
56105654
a.transcriptProcessFoldAvailable = foldExists
@@ -5667,7 +5711,7 @@ func (a *App) rebuildTranscript() {
56675711
if inlineLog && lastRenderedRole == roleAssistant {
56685712
continuation = true
56695713
}
5670-
rendered, _ := a.renderMessageBlockWithCopy(message, width, 0, !continuation)
5714+
rendered := a.renderMessageBlockForTranscript(message, width, !continuation)
56715715
if rendered == "" {
56725716
continue
56735717
}
@@ -5695,15 +5739,36 @@ func (a *App) rebuildTranscript() {
56955739
}
56965740

56975741
if !hasBlock {
5698-
a.setTranscriptContent(a.styles.empty.Width(width).Render(emptyConversationText))
5699-
a.transcript.GotoTop()
5700-
return
5742+
return "", false
57015743
}
57025744

5703-
a.setTranscriptContent(builder.String())
5704-
if atBottom {
5705-
a.transcript.GotoBottom()
5745+
return builder.String(), true
5746+
}
5747+
5748+
func (a *App) renderMessageBlockForTranscript(message providertypes.Message, width int, includeTag bool) string {
5749+
if a.transcriptBlockRenderCache == nil {
5750+
a.transcriptBlockRenderCache = make(map[string]string)
5751+
}
5752+
key := transcriptBlockRenderCacheKey(message, width, includeTag)
5753+
if cached, ok := a.transcriptBlockRenderCache[key]; ok {
5754+
return cached
5755+
}
5756+
5757+
rendered, _ := a.renderMessageBlockWithCopy(message, width, 0, includeTag)
5758+
if rendered == "" {
5759+
return ""
57065760
}
5761+
if len(a.transcriptBlockRenderCache) >= transcriptBlockRenderCacheMax {
5762+
clear(a.transcriptBlockRenderCache)
5763+
}
5764+
a.transcriptBlockRenderCache[key] = rendered
5765+
return rendered
5766+
}
5767+
5768+
func transcriptBlockRenderCacheKey(message providertypes.Message, width int, includeTag bool) string {
5769+
content := renderMessagePartsForDisplay(message.Parts)
5770+
sum := sha256.Sum256([]byte(content))
5771+
return fmt.Sprintf("%s|%t|%d|%t|%x", message.Role, message.IsError, width, includeTag, sum[:8])
57075772
}
57085773

57095774
func (a *App) toggleTranscriptProcessExpansion() bool {
@@ -5736,7 +5801,7 @@ func (a *App) toggleTranscriptProcessExpansionWithAnchor(anchorViewportRow int,
57365801
} else {
57375802
a.state.StatusText = "Process output collapsed"
57385803
}
5739-
a.rebuildTranscript()
5804+
a.rebuildTranscriptForFoldToggle()
57405805
if anchorViewportRow >= 0 {
57415806
a.pinTranscriptProcessControlRow(anchorViewportRow, controlOrdinal)
57425807
}
@@ -6024,6 +6089,8 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) {
60246089
return true, a.handleSkillCommand(rest)
60256090
case slashCommandCheckpoint:
60266091
return true, a.handleCheckpointCommand(rest)
6092+
case slashCommandWeb:
6093+
return true, a.handleWebCommand(rest)
60276094
case slashCommandSession:
60286095
if err := a.ensureSessionSwitchAllowed(""); err != nil {
60296096
a.state.ExecutionError = err.Error()
@@ -6101,6 +6168,7 @@ func (a *App) startDraftSession() {
61016168
a.startupScreenLocked = false
61026169
a.state.ActiveSessionTitle = draftSessionTitle
61036170
a.activeMessages = nil
6171+
clear(a.transcriptBlockRenderCache)
61046172
a.transcriptProcessFoldAvailable = false
61056173
a.transcriptProcessExpanded = false
61066174
a.clearActivities()

0 commit comments

Comments
 (0)