From 688b5dfd2d6916887f9ba142a491bad2772999af Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 10:49:16 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(tui):=20=E6=8E=A5=E5=85=A5=E5=8F=AF?= =?UTF-8?q?=E6=8E=A7=20Markdown=20=E6=B8=B2=E6=9F=93=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 13 +++- go.sum | 27 +++++++ internal/tui/app.go | 74 +++++++++--------- internal/tui/markdown_renderer.go | 100 +++++++++++++++++++++++++ internal/tui/markdown_renderer_test.go | 75 +++++++++++++++++++ internal/tui/update_test.go | 65 +++++++++++++++- internal/tui/view.go | 17 +++++ 7 files changed, 335 insertions(+), 36 deletions(-) create mode 100644 internal/tui/markdown_renderer.go create mode 100644 internal/tui/markdown_renderer_test.go diff --git a/go.mod b/go.mod index 8241d472..40c31e10 100644 --- a/go.mod +++ b/go.mod @@ -5,33 +5,44 @@ go 1.25.0 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/joho/godotenv v1.5.1 golang.org/x/net v0.52.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 9a787c6c..8d964625 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -14,14 +18,20 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -34,8 +44,12 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -46,22 +60,33 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= @@ -74,6 +99,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= diff --git a/internal/tui/app.go b/internal/tui/app.go index 08cd4bff..eb442726 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -17,25 +17,26 @@ import ( ) type App struct { - state UIState - configManager *config.Manager - providerSvc ProviderController - runtime agentruntime.Runtime - keys keyMap - help help.Model - spinner spinner.Model - sessions list.Model - providerPicker list.Model - modelPicker list.Model - transcript viewport.Model - input textarea.Model - activeMessages []provider.Message - activities []activityEntry - fileCandidates []string - focus panel - width int - height int - styles styles + state UIState + configManager *config.Manager + providerSvc ProviderController + runtime agentruntime.Runtime + keys keyMap + help help.Model + spinner spinner.Model + sessions list.Model + providerPicker list.Model + modelPicker list.Model + transcript viewport.Model + input textarea.Model + markdownRenderer markdownContentRenderer + activeMessages []provider.Message + activities []activityEntry + fileCandidates []string + focus panel + width int + height int + styles styles } func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { @@ -51,6 +52,10 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } uiStyles := newStyles() + markdownRenderer, err := newMarkdownRenderer() + if err != nil { + return App{}, err + } keys := newKeyMap() delegate := sessionDelegate{styles: uiStyles} sessionList := list.New([]list.Item{}, delegate, 0, 0) @@ -104,21 +109,22 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime ActiveSessionTitle: draftSessionTitle, Focus: panelInput, }, - configManager: configManager, - providerSvc: providerSvc, - runtime: runtime, - keys: keys, - help: h, - spinner: spin, - sessions: sessionList, - providerPicker: newProviderPicker(nil), - modelPicker: newModelPicker(nil), - transcript: viewport.New(0, 0), - input: input, - focus: panelInput, - width: 128, - height: 40, - styles: uiStyles, + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + keys: keys, + help: h, + spinner: spin, + sessions: sessionList, + providerPicker: newProviderPicker(nil), + modelPicker: newModelPicker(nil), + transcript: viewport.New(0, 0), + input: input, + markdownRenderer: markdownRenderer, + focus: panelInput, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { diff --git a/internal/tui/markdown_renderer.go b/internal/tui/markdown_renderer.go new file mode 100644 index 00000000..17fcbeaf --- /dev/null +++ b/internal/tui/markdown_renderer.go @@ -0,0 +1,100 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/glamour" +) + +const ( + defaultMarkdownStyle = "dark" + defaultMarkdownCacheMax = 128 +) + +var markdownANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +type markdownContentRenderer interface { + Render(content string, width int) (string, error) +} + +type glamourMarkdownRenderer struct { + renderers map[int]*glamour.TermRenderer + cache map[string]string + cacheOrder []string + maxCacheEntries int +} + +func newMarkdownRenderer() (markdownContentRenderer, error) { + return &glamourMarkdownRenderer{ + renderers: make(map[int]*glamour.TermRenderer), + cache: make(map[string]string), + cacheOrder: make([]string, 0, defaultMarkdownCacheMax), + maxCacheEntries: defaultMarkdownCacheMax, + }, nil +} + +func (r *glamourMarkdownRenderer) Render(content string, width int) (string, error) { + if strings.TrimSpace(content) == "" { + return emptyMessageText, nil + } + + renderWidth := max(16, width) + cacheKey := fmt.Sprintf("%d:%s", renderWidth, content) + if cached, ok := r.cache[cacheKey]; ok { + return cached, nil + } + + termRenderer, err := r.rendererForWidth(renderWidth) + if err != nil { + return "", err + } + + rendered, err := termRenderer.Render(content) + if err != nil { + return "", err + } + rendered = strings.TrimRight(rendered, "\n") + visible := markdownANSIPattern.ReplaceAllString(rendered, "") + if strings.TrimSpace(visible) == "" { + rendered = emptyMessageText + } + + r.cacheResult(cacheKey, rendered) + return rendered, nil +} + +func (r *glamourMarkdownRenderer) rendererForWidth(width int) (*glamour.TermRenderer, error) { + if renderer, ok := r.renderers[width]; ok { + return renderer, nil + } + + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(defaultMarkdownStyle), + glamour.WithWordWrap(width), + ) + if err != nil { + return nil, err + } + + r.renderers[width] = renderer + return renderer, nil +} + +func (r *glamourMarkdownRenderer) cacheResult(key string, value string) { + if r.maxCacheEntries <= 0 { + return + } + if _, exists := r.cache[key]; exists { + r.cache[key] = value + return + } + if len(r.cacheOrder) >= r.maxCacheEntries { + oldest := r.cacheOrder[0] + r.cacheOrder = r.cacheOrder[1:] + delete(r.cache, oldest) + } + r.cacheOrder = append(r.cacheOrder, key) + r.cache[key] = value +} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/markdown_renderer_test.go new file mode 100644 index 00000000..d78295d8 --- /dev/null +++ b/internal/tui/markdown_renderer_test.go @@ -0,0 +1,75 @@ +package tui + +import "testing" + +func TestNewMarkdownRendererAndRender(t *testing.T) { + rendererAny, err := newMarkdownRenderer() + if err != nil { + t.Fatalf("newMarkdownRenderer() error = %v", err) + } + + renderer, ok := rendererAny.(*glamourMarkdownRenderer) + if !ok { + t.Fatalf("expected glamourMarkdownRenderer type, got %T", rendererAny) + } + + output, err := renderer.Render("# Title\n\n- one\n- two", 40) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if output == "" { + t.Fatalf("expected non-empty markdown output") + } + if len(renderer.renderers) != 1 { + t.Fatalf("expected one cached term renderer, got %d", len(renderer.renderers)) + } + if len(renderer.cache) != 1 { + t.Fatalf("expected one cached render result, got %d", len(renderer.cache)) + } +} + +func TestMarkdownRendererHandlesEmptyInputAndCacheEviction(t *testing.T) { + rendererAny, err := newMarkdownRenderer() + if err != nil { + t.Fatalf("newMarkdownRenderer() error = %v", err) + } + renderer := rendererAny.(*glamourMarkdownRenderer) + + emptyOutput, err := renderer.Render(" \n\t ", 32) + if err != nil { + t.Fatalf("Render(empty) error = %v", err) + } + if emptyOutput != emptyMessageText { + t.Fatalf("expected empty message placeholder, got %q", emptyOutput) + } + + renderer.maxCacheEntries = 1 + if _, err := renderer.Render("first", 20); err != nil { + t.Fatalf("Render(first) error = %v", err) + } + if _, err := renderer.Render("second", 20); err != nil { + t.Fatalf("Render(second) error = %v", err) + } + if len(renderer.cacheOrder) != 1 || len(renderer.cache) != 1 { + t.Fatalf("expected cache eviction to keep one entry, got order=%d cache=%d", len(renderer.cacheOrder), len(renderer.cache)) + } +} + +func TestMarkdownRendererCachesByWidth(t *testing.T) { + rendererAny, err := newMarkdownRenderer() + if err != nil { + t.Fatalf("newMarkdownRenderer() error = %v", err) + } + renderer := rendererAny.(*glamourMarkdownRenderer) + + text := "plain text" + if _, err := renderer.Render(text, 20); err != nil { + t.Fatalf("Render(width=20) error = %v", err) + } + if _, err := renderer.Render(text, 50); err != nil { + t.Fatalf("Render(width=50) error = %v", err) + } + if len(renderer.renderers) != 2 { + t.Fatalf("expected width-specific renderer cache, got %d", len(renderer.renderers)) + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 40207d75..d44813dd 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -3,6 +3,8 @@ package tui import ( "bytes" "context" + "errors" + "regexp" "strings" "testing" "time" @@ -30,6 +32,25 @@ type stubRuntime struct { cancelResult bool } +type stubMarkdownRenderer struct { + output string + err error + calls int +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func (r *stubMarkdownRenderer) Render(content string, width int) (string, error) { + r.calls++ + if r.err != nil { + return "", r.err + } + if r.output != "" { + return r.output, nil + } + return content, nil +} + func newStubRuntime() *stubRuntime { return &stubRuntime{ events: make(chan agentruntime.RuntimeEvent, 16), @@ -1331,7 +1352,7 @@ func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { } rendered := app.renderMessageContent("```\n```", 20, app.styles.messageBody) - if !strings.Contains(rendered, emptyMessageText) { + if !strings.Contains(stripANSI(rendered), emptyMessageText) { t.Fatalf("expected empty code block placeholder, got %q", rendered) } } @@ -1480,6 +1501,7 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { } rendered := app.renderMessageContent("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody) + rendered = stripANSI(rendered) if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { t.Fatalf("expected mixed prose and code to render, got %q", rendered) } @@ -1496,6 +1518,43 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { } } +func TestRenderMessageContentUsesMarkdownRenderer(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + stub := &stubMarkdownRenderer{output: "markdown-rendered"} + app.markdownRenderer = stub + + rendered := app.renderMessageContent("# Title\n\n- item", 40, app.styles.messageBody) + if !strings.Contains(rendered, "markdown-rendered") { + t.Fatalf("expected markdown renderer output, got %q", rendered) + } + if stub.calls != 1 { + t.Fatalf("expected markdown renderer to be called once, got %d", stub.calls) + } +} + +func TestRenderMessageContentFallsBackWhenMarkdownFails(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.markdownRenderer = &stubMarkdownRenderer{err: errors.New("render failed")} + + content := "before\n```go\nfmt.Println(1)\n```\nafter" + rendered := app.renderMessageContent(content, 50, app.styles.messageBody) + if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { + t.Fatalf("expected legacy markdown fallback output, got %q", rendered) + } +} + func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { previousExecutor := workspaceCommandExecutor t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) @@ -1622,3 +1681,7 @@ func collectTeaMessages(cmd tea.Cmd) []tea.Msg { return []tea.Msg{typed} } } + +func stripANSI(input string) string { + return ansiPattern.ReplaceAllString(input, "") +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 6b31ae7a..dd30253b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "github.com/charmbracelet/bubbles/list" @@ -347,6 +348,22 @@ func (a App) renderHelp(width int) string { } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { + rendered, err := a.renderMarkdownContent(content, max(16, width-2)) + if err == nil { + return bodyStyle.Render(rendered) + } + + return a.renderMessageContentLegacy(content, width, bodyStyle) +} + +func (a App) renderMarkdownContent(content string, width int) (string, error) { + if a.markdownRenderer == nil { + return "", fmt.Errorf("tui: markdown renderer is nil") + } + return a.markdownRenderer.Render(content, width) +} + +func (a App) renderMessageContentLegacy(content string, width int, bodyStyle lipgloss.Style) string { parts := strings.Split(content, "```") if len(parts) == 1 { return bodyStyle.Render(wrapPlain(content, max(16, width-2))) From 3176e904093daf8fc2b37c7300bc42200f30ac46 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 10:51:56 +0800 Subject: [PATCH 02/13] =?UTF-8?q?refactor(tui):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B8=B2=E6=9F=93=E6=97=A7=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/update_test.go | 22 +++++++++++++--- internal/tui/view.go | 50 +++---------------------------------- 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index d44813dd..709cde7a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1538,7 +1538,7 @@ func TestRenderMessageContentUsesMarkdownRenderer(t *testing.T) { } } -func TestRenderMessageContentFallsBackWhenMarkdownFails(t *testing.T) { +func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) @@ -1550,8 +1550,24 @@ func TestRenderMessageContentFallsBackWhenMarkdownFails(t *testing.T) { content := "before\n```go\nfmt.Println(1)\n```\nafter" rendered := app.renderMessageContent(content, 50, app.styles.messageBody) - if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { - t.Fatalf("expected legacy markdown fallback output, got %q", rendered) + if !strings.Contains(rendered, emptyMessageText) { + t.Fatalf("expected placeholder when markdown render fails, got %q", rendered) + } +} + +func TestRenderMessageContentShowsPlaceholderWhenRendererMissing(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.markdownRenderer = nil + + rendered := app.renderMessageContent("content", 50, app.styles.messageBody) + if !strings.Contains(rendered, emptyMessageText) { + t.Fatalf("expected placeholder when markdown renderer is missing, got %q", rendered) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index dd30253b..0a2ae4e1 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "github.com/charmbracelet/bubbles/list" @@ -348,56 +347,15 @@ func (a App) renderHelp(width int) string { } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { - rendered, err := a.renderMarkdownContent(content, max(16, width-2)) - if err == nil { - return bodyStyle.Render(rendered) - } - - return a.renderMessageContentLegacy(content, width, bodyStyle) -} - -func (a App) renderMarkdownContent(content string, width int) (string, error) { if a.markdownRenderer == nil { - return "", fmt.Errorf("tui: markdown renderer is nil") - } - return a.markdownRenderer.Render(content, width) -} - -func (a App) renderMessageContentLegacy(content string, width int, bodyStyle lipgloss.Style) string { - parts := strings.Split(content, "```") - if len(parts) == 1 { - return bodyStyle.Render(wrapPlain(content, max(16, width-2))) - } - - blocks := make([]string, 0, len(parts)) - for i, part := range parts { - if i%2 == 0 { - trimmed := strings.Trim(part, "\n") - if trimmed == "" { - continue - } - blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) - continue - } - - code := strings.Trim(part, "\n") - lines := strings.Split(code, "\n") - if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { - code = strings.Join(lines[1:], "\n") - } - codeWidth := max(10, width-4) - renderedCode := wrapCodeBlock(code, codeWidth) - if strings.TrimSpace(renderedCode) == "" { - renderedCode = emptyMessageText - } - blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(codeWidth).Render(renderedCode))) + return bodyStyle.Render(emptyMessageText) } - if len(blocks) == 0 { + rendered, err := a.markdownRenderer.Render(content, max(16, width-2)) + if err != nil { return bodyStyle.Render(emptyMessageText) } - - return lipgloss.JoinVertical(lipgloss.Left, blocks...) + return bodyStyle.Render(rendered) } func (a App) statusBadge(text string) string { From 830520baccfd0526b777a74c6da734def0a420fb Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 10:56:04 +0800 Subject: [PATCH 03/13] =?UTF-8?q?fix(tui):=20=E7=BB=9F=E4=B8=80=20UTF-8=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=BC=96=E7=A0=81=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B8=B2=E6=9F=93=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap.go | 2 + internal/app/console_encoding.go | 16 ++++++ internal/app/console_encoding_nonwindows.go | 11 ++++ internal/app/console_encoding_test.go | 62 +++++++++++++++++++++ internal/app/console_encoding_windows.go | 13 +++++ internal/tui/markdown_renderer_test.go | 26 ++++++++- internal/tui/view.go | 4 +- 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 internal/app/console_encoding.go create mode 100644 internal/app/console_encoding_nonwindows.go create mode 100644 internal/app/console_encoding_test.go create mode 100644 internal/app/console_encoding_windows.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 3578c1da..42369fb4 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -20,6 +20,8 @@ import ( ) func NewProgram(ctx context.Context) (*tea.Program, error) { + ensureConsoleUTF8() + loader := config.NewLoader("", builtin.DefaultConfig()) manager := config.NewManager(loader) cfg, err := manager.Load(ctx) diff --git a/internal/app/console_encoding.go b/internal/app/console_encoding.go new file mode 100644 index 00000000..030f6568 --- /dev/null +++ b/internal/app/console_encoding.go @@ -0,0 +1,16 @@ +package app + +const utf8CodePage = 65001 + +var ( + setConsoleOutputCodePage = platformSetConsoleOutputCodePage + setConsoleInputCodePage = platformSetConsoleInputCodePage +) + +// ensureConsoleUTF8 is best-effort and should never block app startup. +func ensureConsoleUTF8() { + if err := setConsoleOutputCodePage(utf8CodePage); err != nil { + return + } + _ = setConsoleInputCodePage(utf8CodePage) +} diff --git a/internal/app/console_encoding_nonwindows.go b/internal/app/console_encoding_nonwindows.go new file mode 100644 index 00000000..7ea0f77a --- /dev/null +++ b/internal/app/console_encoding_nonwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package app + +func platformSetConsoleOutputCodePage(codePage uint32) error { + return nil +} + +func platformSetConsoleInputCodePage(codePage uint32) error { + return nil +} diff --git a/internal/app/console_encoding_test.go b/internal/app/console_encoding_test.go new file mode 100644 index 00000000..a3aa874b --- /dev/null +++ b/internal/app/console_encoding_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "errors" + "testing" +) + +func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { + originalOutput := setConsoleOutputCodePage + originalInput := setConsoleInputCodePage + t.Cleanup(func() { + setConsoleOutputCodePage = originalOutput + setConsoleInputCodePage = originalInput + }) + + calls := make([]string, 0, 2) + setConsoleOutputCodePage = func(codePage uint32) error { + if codePage != utf8CodePage { + t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) + } + calls = append(calls, "output") + return nil + } + setConsoleInputCodePage = func(codePage uint32) error { + if codePage != utf8CodePage { + t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) + } + calls = append(calls, "input") + return nil + } + + ensureConsoleUTF8() + + if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { + t.Fatalf("expected output->input order, got %+v", calls) + } +} + +func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { + originalOutput := setConsoleOutputCodePage + originalInput := setConsoleInputCodePage + t.Cleanup(func() { + setConsoleOutputCodePage = originalOutput + setConsoleInputCodePage = originalInput + }) + + outputErr := errors.New("output failed") + setConsoleOutputCodePage = func(codePage uint32) error { + return outputErr + } + inputCalled := false + setConsoleInputCodePage = func(codePage uint32) error { + inputCalled = true + return nil + } + + ensureConsoleUTF8() + + if inputCalled { + t.Fatalf("expected input code page setup to be skipped when output setup fails") + } +} diff --git a/internal/app/console_encoding_windows.go b/internal/app/console_encoding_windows.go new file mode 100644 index 00000000..3c8eb5ef --- /dev/null +++ b/internal/app/console_encoding_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package app + +import "golang.org/x/sys/windows" + +func platformSetConsoleOutputCodePage(codePage uint32) error { + return windows.SetConsoleOutputCP(codePage) +} + +func platformSetConsoleInputCodePage(codePage uint32) error { + return windows.SetConsoleCP(codePage) +} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/markdown_renderer_test.go index d78295d8..1dbc4f72 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/markdown_renderer_test.go @@ -1,6 +1,12 @@ package tui -import "testing" +import ( + "regexp" + "strings" + "testing" +) + +var markdownTestANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) func TestNewMarkdownRendererAndRender(t *testing.T) { rendererAny, err := newMarkdownRenderer() @@ -73,3 +79,21 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { t.Fatalf("expected width-specific renderer cache, got %d", len(renderer.renderers)) } } + +func TestMarkdownRendererPreservesChineseText(t *testing.T) { + rendererAny, err := newMarkdownRenderer() + if err != nil { + t.Fatalf("newMarkdownRenderer() error = %v", err) + } + renderer := rendererAny.(*glamourMarkdownRenderer) + + output, err := renderer.Render("中文标题\n\n- 第一项\n- 第二项", 40) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + visible := markdownTestANSIPattern.ReplaceAllString(output, "") + if !strings.Contains(visible, "中文标题") || !strings.Contains(visible, "第一项") || !strings.Contains(visible, "第二项") { + t.Fatalf("expected chinese markdown content to be preserved, got %q", visible) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 0a2ae4e1..4eb4198c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -170,7 +170,7 @@ func (a App) renderPrompt(width int) string { box = a.styles.inputBoxFocused } - // 计算边框和内边距占用的空间 + // Account for frame and padding when sizing the composer container. boxWidth := a.composerBoxWidth(width) return box.Width(boxWidth).Render(a.input.View()) @@ -342,7 +342,7 @@ func (a App) commandMenuHeight(width int) int { func (a App) renderHelp(width int) string { a.help.ShowAll = a.state.ShowHelp helpContent := a.help.View(a.keys) - // 确保帮助视图填充整个宽度,避免边框断裂 + // Keep help content stretched to full width to avoid clipping at borders. return a.styles.footer.Width(width).Render(helpContent) } From 9fbd9b9fa5fb81d5a3a889146bf7b321ad04a946 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 10:58:09 +0800 Subject: [PATCH 04/13] =?UTF-8?q?refactor(app):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=BC=96=E7=A0=81=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=E5=88=B0=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap.go | 15 +++++++ internal/app/bootstrap_test.go | 57 ++++++++++++++++++++++++ internal/app/console_encoding.go | 16 ------- internal/app/console_encoding_test.go | 62 --------------------------- 4 files changed, 72 insertions(+), 78 deletions(-) delete mode 100644 internal/app/console_encoding.go delete mode 100644 internal/app/console_encoding_test.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 42369fb4..300c0bdd 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -19,6 +19,21 @@ import ( "neo-code/internal/tui" ) +const utf8CodePage = 65001 + +var ( + setConsoleOutputCodePage = platformSetConsoleOutputCodePage + setConsoleInputCodePage = platformSetConsoleInputCodePage +) + +// ensureConsoleUTF8 is best-effort and should never block app startup. +func ensureConsoleUTF8() { + if err := setConsoleOutputCodePage(utf8CodePage); err != nil { + return + } + _ = setConsoleInputCodePage(utf8CodePage) +} + func NewProgram(ctx context.Context) (*tea.Program, error) { ensureConsoleUTF8() diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 14281848..a32f3bc5 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -104,6 +105,62 @@ func TestBuildToolManagerWrapsRegistry(t *testing.T) { } } +func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { + originalOutput := setConsoleOutputCodePage + originalInput := setConsoleInputCodePage + t.Cleanup(func() { + setConsoleOutputCodePage = originalOutput + setConsoleInputCodePage = originalInput + }) + + calls := make([]string, 0, 2) + setConsoleOutputCodePage = func(codePage uint32) error { + if codePage != utf8CodePage { + t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) + } + calls = append(calls, "output") + return nil + } + setConsoleInputCodePage = func(codePage uint32) error { + if codePage != utf8CodePage { + t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) + } + calls = append(calls, "input") + return nil + } + + ensureConsoleUTF8() + + if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { + t.Fatalf("expected output->input order, got %+v", calls) + } +} + +func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { + originalOutput := setConsoleOutputCodePage + originalInput := setConsoleInputCodePage + t.Cleanup(func() { + setConsoleOutputCodePage = originalOutput + setConsoleInputCodePage = originalInput + }) + + outputErr := errors.New("output failed") + setConsoleOutputCodePage = func(codePage uint32) error { + return outputErr + } + inputCalled := false + setConsoleInputCodePage = func(codePage uint32) error { + inputCalled = true + return nil + } + + ensureConsoleUTF8() + + if inputCalled { + t.Fatalf("expected input code page setup to be skipped when output setup fails") + } +} + type stubToolForBootstrap struct { name string content string diff --git a/internal/app/console_encoding.go b/internal/app/console_encoding.go deleted file mode 100644 index 030f6568..00000000 --- a/internal/app/console_encoding.go +++ /dev/null @@ -1,16 +0,0 @@ -package app - -const utf8CodePage = 65001 - -var ( - setConsoleOutputCodePage = platformSetConsoleOutputCodePage - setConsoleInputCodePage = platformSetConsoleInputCodePage -) - -// ensureConsoleUTF8 is best-effort and should never block app startup. -func ensureConsoleUTF8() { - if err := setConsoleOutputCodePage(utf8CodePage); err != nil { - return - } - _ = setConsoleInputCodePage(utf8CodePage) -} diff --git a/internal/app/console_encoding_test.go b/internal/app/console_encoding_test.go deleted file mode 100644 index a3aa874b..00000000 --- a/internal/app/console_encoding_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package app - -import ( - "errors" - "testing" -) - -func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { - originalOutput := setConsoleOutputCodePage - originalInput := setConsoleInputCodePage - t.Cleanup(func() { - setConsoleOutputCodePage = originalOutput - setConsoleInputCodePage = originalInput - }) - - calls := make([]string, 0, 2) - setConsoleOutputCodePage = func(codePage uint32) error { - if codePage != utf8CodePage { - t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) - } - calls = append(calls, "output") - return nil - } - setConsoleInputCodePage = func(codePage uint32) error { - if codePage != utf8CodePage { - t.Fatalf("expected utf8 code page %d, got %d", utf8CodePage, codePage) - } - calls = append(calls, "input") - return nil - } - - ensureConsoleUTF8() - - if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { - t.Fatalf("expected output->input order, got %+v", calls) - } -} - -func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { - originalOutput := setConsoleOutputCodePage - originalInput := setConsoleInputCodePage - t.Cleanup(func() { - setConsoleOutputCodePage = originalOutput - setConsoleInputCodePage = originalInput - }) - - outputErr := errors.New("output failed") - setConsoleOutputCodePage = func(codePage uint32) error { - return outputErr - } - inputCalled := false - setConsoleInputCodePage = func(codePage uint32) error { - inputCalled = true - return nil - } - - ensureConsoleUTF8() - - if inputCalled { - t.Fatalf("expected input code page setup to be skipped when output setup fails") - } -} From 23575a673c360c15dfd5e50ed5879d9f047a953e Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 13:51:55 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E4=B8=8E=E6=A0=87=E7=AD=BE=E5=8F=B3?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/markdown_renderer.go | 17 +++++++++++-- internal/tui/markdown_renderer_test.go | 20 +++++++++++++++ internal/tui/update_test.go | 35 ++++++++++++++++++++++++++ internal/tui/view.go | 3 --- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/tui/markdown_renderer.go b/internal/tui/markdown_renderer.go index 17fcbeaf..b3e245d3 100644 --- a/internal/tui/markdown_renderer.go +++ b/internal/tui/markdown_renderer.go @@ -55,9 +55,10 @@ func (r *glamourMarkdownRenderer) Render(content string, width int) (string, err if err != nil { return "", err } + rendered = markdownANSIPattern.ReplaceAllString(rendered, "") rendered = strings.TrimRight(rendered, "\n") - visible := markdownANSIPattern.ReplaceAllString(rendered, "") - if strings.TrimSpace(visible) == "" { + rendered = trimMarkdownTrailingWhitespace(rendered) + if strings.TrimSpace(rendered) == "" { rendered = emptyMessageText } @@ -98,3 +99,15 @@ func (r *glamourMarkdownRenderer) cacheResult(key string, value string) { r.cacheOrder = append(r.cacheOrder, key) r.cache[key] = value } + +func trimMarkdownTrailingWhitespace(input string) string { + if input == "" { + return input + } + + lines := strings.Split(input, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " \t") + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/markdown_renderer_test.go index 1dbc4f72..5abceae1 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/markdown_renderer_test.go @@ -97,3 +97,23 @@ func TestMarkdownRendererPreservesChineseText(t *testing.T) { t.Fatalf("expected chinese markdown content to be preserved, got %q", visible) } } + +func TestMarkdownRendererTrimsTrailingWhitespace(t *testing.T) { + rendererAny, err := newMarkdownRenderer() + if err != nil { + t.Fatalf("newMarkdownRenderer() error = %v", err) + } + renderer := rendererAny.(*glamourMarkdownRenderer) + + output, err := renderer.Render("nihao", 48) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + visible := markdownTestANSIPattern.ReplaceAllString(output, "") + for _, line := range strings.Split(visible, "\n") { + if strings.HasSuffix(line, " ") || strings.HasSuffix(line, "\t") { + t.Fatalf("expected no trailing whitespace in line %q", line) + } + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 709cde7a..af2b3611 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1538,6 +1538,41 @@ func TestRenderMessageContentUsesMarkdownRenderer(t *testing.T) { } } +func TestRenderMessageBlockUserContentAlignsWithUserTag(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + rendered := stripANSI(app.renderMessageBlock(provider.Message{Role: roleUser, Content: "nihao"}, 80)) + lines := strings.Split(rendered, "\n") + + tagLine := "" + bodyLine := "" + for _, line := range lines { + if strings.Contains(line, messageTagUser) { + tagLine = line + } + if strings.Contains(line, "nihao") { + bodyLine = line + } + } + if tagLine == "" || bodyLine == "" { + t.Fatalf("expected user tag and body lines, got %q", rendered) + } + + tagCol := strings.Index(tagLine, messageTagUser) + bodyCol := strings.Index(bodyLine, "nihao") + if tagCol < 0 || bodyCol < 0 { + t.Fatalf("expected valid columns for user tag/body, got tag=%d body=%d", tagCol, bodyCol) + } + if bodyCol+6 < tagCol { + t.Fatalf("expected user body to align near user tag, got tagCol=%d bodyCol=%d rendered=%q", tagCol, bodyCol, rendered) + } +} + func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index 4eb4198c..2551d963 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -258,9 +258,6 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { } contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) - if message.Role == roleUser { - contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) - } block := lipgloss.JoinVertical( blockAlign, From 04d4e78d13a200ddd45927452b90ad797aa75cc1 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 13:58:48 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat(tui):=20=E4=B8=BA=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E5=A2=9E=E5=8A=A0=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 2 + internal/tui/commands.go | 4 + internal/tui/copy_code.go | 133 ++++++++++++++++++++++++++ internal/tui/copy_code_test.go | 166 +++++++++++++++++++++++++++++++++ internal/tui/styles.go | 5 + internal/tui/update.go | 11 ++- internal/tui/view.go | 31 ++++-- 7 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 internal/tui/copy_code.go create mode 100644 internal/tui/copy_code_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index eb442726..6284a09c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -30,6 +30,7 @@ type App struct { transcript viewport.Model input textarea.Model markdownRenderer markdownContentRenderer + codeCopyBlocks map[int]string activeMessages []provider.Message activities []activityEntry fileCandidates []string @@ -121,6 +122,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime transcript: viewport.New(0, 0), input: input, markdownRenderer: markdownRenderer, + codeCopyBlocks: make(map[int]string), focus: panelInput, width: 128, height: 40, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ccb805e4..f21b37d4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -72,6 +72,7 @@ const ( messageTagUser = "[ YOU ]" messageTagAgent = "[ NEO ]" messageTagTool = "[ TOOL ]" + copyCodeButton = "[Copy code #%d]" roleUser = "user" roleAssistant = "assistant" @@ -79,6 +80,9 @@ const ( roleEvent = "event" roleError = "error" roleSystem = "system" + + statusCodeCopied = "Copied code block #%d" + statusCodeCopyError = "Failed to copy code block" ) type slashCommand struct { diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go new file mode 100644 index 00000000..047e0b62 --- /dev/null +++ b/internal/tui/copy_code.go @@ -0,0 +1,133 @@ +package tui + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" +) + +type copyCodeButtonBinding struct { + ID int + Code string +} + +var ( + copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) + clipboardWriteAll = clipboard.WriteAll +) + +func collectCopyCodeButtons(content string, startID int) []copyCodeButtonBinding { + codeBlocks := extractFencedCodeBlocks(content) + if len(codeBlocks) == 0 { + return nil + } + + bindings := make([]copyCodeButtonBinding, 0, len(codeBlocks)) + for i, code := range codeBlocks { + bindings = append(bindings, copyCodeButtonBinding{ + ID: startID + i, + Code: code, + }) + } + return bindings +} + +func extractFencedCodeBlocks(content string) []string { + parts := strings.Split(content, "```") + if len(parts) < 3 { + return nil + } + + blocks := make([]string, 0, len(parts)/2) + for i := 1; i < len(parts); i += 2 { + code := strings.Trim(parts[i], "\n") + if code == "" { + continue + } + lines := strings.Split(code, "\n") + if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { + code = strings.Join(lines[1:], "\n") + } + code = strings.TrimSpace(code) + if code == "" { + continue + } + blocks = append(blocks, code) + } + return blocks +} + +func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { + a.codeCopyBlocks = make(map[int]string, len(bindings)) + for _, binding := range bindings { + a.codeCopyBlocks[binding.ID] = binding.Code + } +} + +func parseCopyCodeButtonID(line string) (int, bool) { + clean := ansiEscapePattern.ReplaceAllString(line, "") + matches := copyCodeButtonPattern.FindStringSubmatch(clean) + if len(matches) != 2 { + return 0, false + } + + id, err := strconv.Atoi(matches[1]) + if err != nil { + return 0, false + } + return id, true +} + +func (a *App) handleTranscriptCopyClick(msg tea.MouseMsg) bool { + line, ok := a.transcriptLineAtMouse(msg) + if !ok { + return false + } + + buttonID, ok := parseCopyCodeButtonID(line) + if !ok { + return false + } + + code, ok := a.codeCopyBlocks[buttonID] + if !ok { + a.state.ExecutionError = statusCodeCopyError + a.state.StatusText = statusCodeCopyError + a.appendActivity("clipboard", statusCodeCopyError, fmt.Sprintf("button #%d", buttonID), true) + return true + } + + if err := clipboardWriteAll(code); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = statusCodeCopyError + a.appendActivity("clipboard", statusCodeCopyError, err.Error(), true) + return true + } + + a.state.ExecutionError = "" + a.state.StatusText = fmt.Sprintf(statusCodeCopied, buttonID) + a.appendActivity("clipboard", "Copied code block", fmt.Sprintf("#%d", buttonID), false) + return true +} + +func (a App) transcriptLineAtMouse(msg tea.MouseMsg) (string, bool) { + if !a.isMouseWithinTranscript(msg) { + return "", false + } + + _, y, _, _ := a.transcriptBounds() + lineIndex := msg.Y - y + if lineIndex < 0 { + return "", false + } + + lines := strings.Split(a.transcript.View(), "\n") + if lineIndex >= len(lines) { + return "", false + } + return lines[lineIndex], true +} diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go new file mode 100644 index 00000000..912c050b --- /dev/null +++ b/internal/tui/copy_code_test.go @@ -0,0 +1,166 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/provider" +) + +func TestExtractFencedCodeBlocks(t *testing.T) { + content := "before\n```go\nfmt.Println(1)\n```\nmid\n```bash\necho hi\n```\nafter" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 2 { + t.Fatalf("expected 2 code blocks, got %d", len(blocks)) + } + if blocks[0] != "fmt.Println(1)" { + t.Fatalf("expected first code block to strip language tag, got %q", blocks[0]) + } + if blocks[1] != "echo hi" { + t.Fatalf("expected second code block to strip language tag, got %q", blocks[1]) + } +} + +func TestParseCopyCodeButtonID(t *testing.T) { + id, ok := parseCopyCodeButtonID("[Copy code #12]") + if !ok || id != 12 { + t.Fatalf("expected id=12 parse success, got id=%d ok=%v", id, ok) + } + + if _, ok := parseCopyCodeButtonID("no button"); ok { + t.Fatalf("expected parse failure for non-button line") + } +} + +func TestRenderMessageBlockWithCopyAddsButtons(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, "```go\nfmt.Println(1)\n```"), 80, 1) + if !strings.Contains(rendered, "[Copy code #1]") { + t.Fatalf("expected copy button in rendered message, got %q", rendered) + } + if len(bindings) != 1 || bindings[0].ID != 1 || bindings[0].Code != "fmt.Println(1)" { + t.Fatalf("unexpected bindings: %+v", bindings) + } +} + +func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + originalClipboardWrite := clipboardWriteAll + t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) + + copied := "" + clipboardWriteAll = func(text string) error { + copied = text + return nil + } + + app.width = 128 + app.height = 40 + app.activeMessages = []provider.Message{ + {Role: roleAssistant, Content: "```go\nfmt.Println(1)\n```"}, + } + app.resizeComponents() + app.rebuildTranscript() + + x, y, _, _ := app.transcriptBounds() + lines := strings.Split(stripANSI(app.transcript.View()), "\n") + targetY := -1 + targetX := -1 + for i, line := range lines { + col := strings.Index(line, "[Copy code #1]") + if col >= 0 { + targetY = i + targetX = col + break + } + } + if targetY < 0 || targetX < 0 { + t.Fatalf("expected visible copy button in transcript view, got %q", app.transcript.View()) + } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + targetX + 1, + Y: y + targetY, + Button: tea.MouseButtonLeft, + }); !handled { + t.Fatalf("expected mouse click on copy button to be handled") + } + + if copied != "fmt.Println(1)" { + t.Fatalf("expected copied code block content, got %q", copied) + } + if !strings.Contains(app.state.StatusText, "Copied code block #1") { + t.Fatalf("expected copy success status, got %q", app.state.StatusText) + } +} + +func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + originalClipboardWrite := clipboardWriteAll + t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) + + clipboardWriteAll = func(text string) error { + return errors.New("clipboard unavailable") + } + + app.width = 128 + app.height = 40 + app.activeMessages = []provider.Message{ + {Role: roleAssistant, Content: "```txt\nhello\n```"}, + } + app.resizeComponents() + app.rebuildTranscript() + + x, y, _, _ := app.transcriptBounds() + lines := strings.Split(stripANSI(app.transcript.View()), "\n") + targetY := -1 + targetX := -1 + for i, line := range lines { + col := strings.Index(line, "[Copy code #1]") + if col >= 0 { + targetY = i + targetX = col + break + } + } + if targetY < 0 || targetX < 0 { + t.Fatalf("expected visible copy button in transcript view") + } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + targetX + 1, + Y: y + targetY, + Button: tea.MouseButtonLeft, + }); !handled { + t.Fatalf("expected mouse click on copy button to be handled") + } + + if app.state.StatusText != statusCodeCopyError || app.state.ExecutionError == "" { + t.Fatalf("expected copy failure status/error, got status=%q err=%q", app.state.StatusText, app.state.ExecutionError) + } +} + +func providerMessage(role, content string) provider.Message { + return provider.Message{Role: role, Content: content} +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 82b69099..5dd5f2f4 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -57,6 +57,7 @@ type styles struct { inlineSystem lipgloss.Style codeBlock lipgloss.Style codeText lipgloss.Style + codeCopyButton lipgloss.Style commandMenu lipgloss.Style commandMenuTitle lipgloss.Style commandUsage lipgloss.Style @@ -185,6 +186,10 @@ func newStyles() styles { BorderForeground(lipgloss.Color(colorPrimary)), codeText: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)), + codeCopyButton: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPrimary)). + Underline(true), commandMenu: lipgloss.NewStyle(). UnsetBackground(). Padding(1, 1), diff --git a/internal/tui/update.go b/internal/tui/update.go index 958d2f90..f8f8c893 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -579,6 +579,8 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { case tea.MouseButtonWheelDown: a.transcript.LineDown(mouseWheelStepLines) return true + case tea.MouseButtonLeft: + return a.handleTranscriptCopyClick(msg) default: return false } @@ -716,6 +718,7 @@ func (a *App) normalizeComposerHeight() { func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { + a.setCodeCopyBlocks(nil) a.transcript.SetContent(a.styles.empty.Width(width).Render(emptyConversationText)) a.transcript.GotoTop() return @@ -723,9 +726,15 @@ func (a *App) rebuildTranscript() { atBottom := a.transcript.AtBottom() blocks := make([]string, 0, len(a.activeMessages)) + copyButtons := make([]copyCodeButtonBinding, 0, 4) + nextCopyID := 1 for _, message := range a.activeMessages { - blocks = append(blocks, a.renderMessageBlock(message, width)) + rendered, bindings := a.renderMessageBlockWithCopy(message, width, nextCopyID) + blocks = append(blocks, rendered) + copyButtons = append(copyButtons, bindings...) + nextCopyID += len(bindings) } + a.setCodeCopyBlocks(copyButtons) a.transcript.SetContent(strings.Join(blocks, "\n\n")) if atBottom || a.state.IsAgentRunning { diff --git a/internal/tui/view.go b/internal/tui/view.go index 2551d963..4d713e41 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "github.com/charmbracelet/bubbles/list" @@ -217,13 +218,18 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, } func (a App) renderMessageBlock(message provider.Message, width int) string { + rendered, _ := a.renderMessageBlockWithCopy(message, width, 1) + return rendered +} + +func (a App) renderMessageBlockWithCopy(message provider.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { switch message.Role { case roleEvent: - return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))), nil case roleError: - return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))), nil case roleSystem: - return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) + return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))), nil } maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) @@ -258,17 +264,22 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { } contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) + copyButtons := collectCopyCodeButtons(content, startCopyID) - block := lipgloss.JoinVertical( - blockAlign, - tagStyle.Render(tag), - contentBlock, - ) + parts := []string{tagStyle.Render(tag), contentBlock} + if len(copyButtons) > 0 { + buttonLines := make([]string, 0, len(copyButtons)) + for _, button := range copyButtons { + buttonLines = append(buttonLines, a.styles.codeCopyButton.Render(fmt.Sprintf(copyCodeButton, button.ID))) + } + parts = append(parts, lipgloss.JoinVertical(blockAlign, buttonLines...)) + } + block := lipgloss.JoinVertical(blockAlign, parts...) if message.Role == roleUser { - return lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + return lipgloss.PlaceHorizontal(width, lipgloss.Right, block), nil } - return block + return block, copyButtons } func (a App) renderCommandMenu(width int) string { From 38ced9adc76753258d39675a49d12fd715b77ac8 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 14:02:12 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(tui):=20=E7=BB=9F=E4=B8=80=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=BA=95=E6=9D=BF=E5=8F=B3=E8=BE=B9=E7=BC=98=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=94=AF=E9=BD=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/update_test.go | 26 ++++++++++++++++++++++++++ internal/tui/view.go | 22 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index af2b3611..3cc0ed6e 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1573,6 +1573,32 @@ func TestRenderMessageBlockUserContentAlignsWithUserTag(t *testing.T) { } } +func TestRenderMessageContentNormalizesRightEdge(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.markdownRenderer = &stubMarkdownRenderer{ + output: "very long line\nshort\nmid", + } + + rendered := stripANSI(app.renderMessageContent("ignored", 40, app.styles.messageBody)) + lines := strings.Split(rendered, "\n") + if len(lines) < 3 { + t.Fatalf("expected multiline output, got %q", rendered) + } + + firstWidth := len([]rune(lines[0])) + for i, line := range lines[1:] { + if len([]rune(line)) != firstWidth { + t.Fatalf("expected aligned right edge, line %d width=%d first=%d rendered=%q", i+1, len([]rune(line)), firstWidth, rendered) + } + } +} + func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index 4d713e41..0c6ad240 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -363,7 +363,27 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. if err != nil { return bodyStyle.Render(emptyMessageText) } - return bodyStyle.Render(rendered) + return bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width))) +} + +func normalizeBlockRightEdge(content string, maxWidth int) string { + if strings.TrimSpace(content) == "" { + return content + } + + lines := strings.Split(content, "\n") + targetWidth := 0 + for _, line := range lines { + targetWidth = max(targetWidth, lipgloss.Width(line)) + } + targetWidth = clamp(targetWidth, 1, maxWidth) + + padStyle := lipgloss.NewStyle().Width(targetWidth) + normalized := make([]string, 0, len(lines)) + for _, line := range lines { + normalized = append(normalized, padStyle.Render(line)) + } + return strings.Join(normalized, "\n") } func (a App) statusBadge(text string) string { From e7ce9341764b9a85f20a21421d8eb8e74f16ff63 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 14:11:08 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat(tui):=20=E5=B0=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE=E8=B4=B4=E5=88=B0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E5=8F=B3=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/copy_code.go | 118 +++++++++++++++++++++------------ internal/tui/copy_code_test.go | 15 ++++- internal/tui/update_test.go | 12 +++- internal/tui/view.go | 84 ++++++++++++++++++----- 4 files changed, 165 insertions(+), 64 deletions(-) diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go index 047e0b62..790c89c5 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/copy_code.go @@ -8,6 +8,7 @@ import ( "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type copyCodeButtonBinding struct { @@ -15,52 +16,73 @@ type copyCodeButtonBinding struct { Code string } +type markdownSegmentKind int + +const ( + markdownSegmentText markdownSegmentKind = iota + markdownSegmentCode +) + +type markdownSegment struct { + Kind markdownSegmentKind + Text string +} + var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) clipboardWriteAll = clipboard.WriteAll ) -func collectCopyCodeButtons(content string, startID int) []copyCodeButtonBinding { - codeBlocks := extractFencedCodeBlocks(content) - if len(codeBlocks) == 0 { - return nil - } - - bindings := make([]copyCodeButtonBinding, 0, len(codeBlocks)) - for i, code := range codeBlocks { - bindings = append(bindings, copyCodeButtonBinding{ - ID: startID + i, - Code: code, - }) - } - return bindings -} - -func extractFencedCodeBlocks(content string) []string { +func splitMarkdownSegments(content string) []markdownSegment { parts := strings.Split(content, "```") - if len(parts) < 3 { - return nil + if len(parts) == 1 { + return []markdownSegment{{Kind: markdownSegmentText, Text: content}} } - blocks := make([]string, 0, len(parts)/2) - for i := 1; i < len(parts); i += 2 { - code := strings.Trim(parts[i], "\n") - if code == "" { + segments := make([]markdownSegment, 0, len(parts)) + for i, part := range parts { + if i%2 == 0 { + if part != "" { + segments = append(segments, markdownSegment{Kind: markdownSegmentText, Text: part}) + } continue } - lines := strings.Split(code, "\n") - if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { - code = strings.Join(lines[1:], "\n") - } - code = strings.TrimSpace(code) + + code := extractCodeBlockContent(part) if code == "" { continue } - blocks = append(blocks, code) + segments = append(segments, markdownSegment{Kind: markdownSegmentCode, Text: code}) + } + if len(segments) == 0 { + return []markdownSegment{{Kind: markdownSegmentText, Text: content}} + } + return segments +} + +func extractFencedCodeBlocks(content string) []string { + segments := splitMarkdownSegments(content) + blocks := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment.Kind == markdownSegmentCode && strings.TrimSpace(segment.Text) != "" { + blocks = append(blocks, strings.TrimSpace(segment.Text)) + } } return blocks } +func extractCodeBlockContent(raw string) string { + code := strings.Trim(raw, "\n") + if code == "" { + return "" + } + lines := strings.Split(code, "\n") + if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { + code = strings.Join(lines[1:], "\n") + } + return strings.TrimSpace(code) +} + func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { a.codeCopyBlocks = make(map[int]string, len(bindings)) for _, binding := range bindings { @@ -68,30 +90,38 @@ func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { } } -func parseCopyCodeButtonID(line string) (int, bool) { +func parseCopyCodeButton(line string) (id int, startCol int, endCol int, ok bool) { clean := ansiEscapePattern.ReplaceAllString(line, "") - matches := copyCodeButtonPattern.FindStringSubmatch(clean) - if len(matches) != 2 { - return 0, false + matches := copyCodeButtonPattern.FindStringSubmatchIndex(clean) + if len(matches) < 4 { + return 0, 0, 0, false } - id, err := strconv.Atoi(matches[1]) + buttonText := clean[matches[0]:matches[1]] + idText := clean[matches[2]:matches[3]] + id, err := strconv.Atoi(idText) if err != nil { - return 0, false + return 0, 0, 0, false } - return id, true + + startCol = lipgloss.Width(clean[:matches[0]]) + endCol = startCol + lipgloss.Width(buttonText) + return id, startCol, endCol, true } func (a *App) handleTranscriptCopyClick(msg tea.MouseMsg) bool { - line, ok := a.transcriptLineAtMouse(msg) + line, relativeX, ok := a.transcriptLineAtMouse(msg) if !ok { return false } - buttonID, ok := parseCopyCodeButtonID(line) + buttonID, startCol, endCol, ok := parseCopyCodeButton(line) if !ok { return false } + if relativeX < startCol || relativeX >= endCol { + return false + } code, ok := a.codeCopyBlocks[buttonID] if !ok { @@ -114,20 +144,20 @@ func (a *App) handleTranscriptCopyClick(msg tea.MouseMsg) bool { return true } -func (a App) transcriptLineAtMouse(msg tea.MouseMsg) (string, bool) { +func (a App) transcriptLineAtMouse(msg tea.MouseMsg) (line string, relativeX int, ok bool) { if !a.isMouseWithinTranscript(msg) { - return "", false + return "", 0, false } - _, y, _, _ := a.transcriptBounds() + x, y, _, _ := a.transcriptBounds() lineIndex := msg.Y - y if lineIndex < 0 { - return "", false + return "", 0, false } lines := strings.Split(a.transcript.View(), "\n") if lineIndex >= len(lines) { - return "", false + return "", 0, false } - return lines[lineIndex], true + return lines[lineIndex], msg.X - x, true } diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 912c050b..88f0c480 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -25,12 +25,15 @@ func TestExtractFencedCodeBlocks(t *testing.T) { } func TestParseCopyCodeButtonID(t *testing.T) { - id, ok := parseCopyCodeButtonID("[Copy code #12]") + id, startCol, endCol, ok := parseCopyCodeButton("[Copy code #12]") if !ok || id != 12 { t.Fatalf("expected id=12 parse success, got id=%d ok=%v", id, ok) } + if startCol != 0 || endCol <= startCol { + t.Fatalf("expected valid button range, got start=%d end=%d", startCol, endCol) + } - if _, ok := parseCopyCodeButtonID("no button"); ok { + if _, _, _, ok := parseCopyCodeButton("no button"); ok { t.Fatalf("expected parse failure for non-button line") } } @@ -107,6 +110,14 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { if !strings.Contains(app.state.StatusText, "Copied code block #1") { t.Fatalf("expected copy success status, got %q", app.state.StatusText) } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + 1, + Y: y + targetY, + Button: tea.MouseButtonLeft, + }); handled { + t.Fatalf("expected click outside copy button text to be ignored") + } } func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3cc0ed6e..7eef3db5 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1502,9 +1502,12 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { rendered := app.renderMessageContent("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody) rendered = stripANSI(rendered) - if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { + if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(") || !strings.Contains(rendered, "1)") || !strings.Contains(rendered, "after") { t.Fatalf("expected mixed prose and code to render, got %q", rendered) } + if !strings.Contains(rendered, "[Copy code #1]") { + t.Fatalf("expected copy button alongside code block, got %q", rendered) + } if got := compactStatusText("\n hello world \n", 0); got != "hello world" { t.Fatalf("expected compact status without truncation, got %q", got) @@ -1611,8 +1614,11 @@ func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { content := "before\n```go\nfmt.Println(1)\n```\nafter" rendered := app.renderMessageContent(content, 50, app.styles.messageBody) - if !strings.Contains(rendered, emptyMessageText) { - t.Fatalf("expected placeholder when markdown render fails, got %q", rendered) + if !strings.Contains(rendered, "fmt.Println(1)") { + t.Fatalf("expected code block to keep rendering when markdown prose fails, got %q", rendered) + } + if !strings.Contains(rendered, "[Copy code #1]") { + t.Fatalf("expected copy button for rendered code block, got %q", rendered) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 0c6ad240..9e29a404 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -263,17 +263,8 @@ func (a App) renderMessageBlockWithCopy(message provider.Message, width int, sta content = emptyMessageText } - contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) - copyButtons := collectCopyCodeButtons(content, startCopyID) - + contentBlock, copyButtons := a.renderMessageContentWithCopy(content, maxMessageWidth-2, bodyStyle, startCopyID) parts := []string{tagStyle.Render(tag), contentBlock} - if len(copyButtons) > 0 { - buttonLines := make([]string, 0, len(copyButtons)) - for _, button := range copyButtons { - buttonLines = append(buttonLines, a.styles.codeCopyButton.Render(fmt.Sprintf(copyCodeButton, button.ID))) - } - parts = append(parts, lipgloss.JoinVertical(blockAlign, buttonLines...)) - } block := lipgloss.JoinVertical(blockAlign, parts...) if message.Role == roleUser { @@ -355,15 +346,78 @@ func (a App) renderHelp(width int) string { } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { + rendered, _ := a.renderMessageContentWithCopy(content, width, bodyStyle, 1) + return rendered +} + +func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle lipgloss.Style, startCopyID int) (string, []copyCodeButtonBinding) { if a.markdownRenderer == nil { - return bodyStyle.Render(emptyMessageText) + return bodyStyle.Render(emptyMessageText), nil + } + + segments := splitMarkdownSegments(content) + if len(segments) == 1 && segments[0].Kind == markdownSegmentText { + rendered, err := a.markdownRenderer.Render(content, max(16, width-2)) + if err != nil { + return bodyStyle.Render(emptyMessageText), nil + } + return bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width))), nil + } + + renderedParts := make([]string, 0, len(segments)) + copyBindings := make([]copyCodeButtonBinding, 0, 2) + nextCopyID := startCopyID + + for _, segment := range segments { + switch segment.Kind { + case markdownSegmentText: + if strings.TrimSpace(segment.Text) == "" { + continue + } + rendered, err := a.markdownRenderer.Render(segment.Text, max(16, width-2)) + if err != nil { + continue + } + renderedParts = append(renderedParts, bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width)))) + case markdownSegmentCode: + code := strings.TrimSpace(segment.Text) + if code == "" { + continue + } + buttonText := fmt.Sprintf(copyCodeButton, nextCopyID) + button := a.styles.codeCopyButton.Render(buttonText) + buttonWidth := lipgloss.Width(button) + + codeAreaWidth := max(16, width-buttonWidth-2) + codeTextWidth := max(8, codeAreaWidth-4) + renderedCode := wrapCodeBlock(code, codeTextWidth) + codePanel := a.styles.codeBlock.Width(codeAreaWidth).Render(a.styles.codeText.Width(codeTextWidth).Render(renderedCode)) + + var codeBlock string + if codeAreaWidth+1+buttonWidth <= width { + codeBlock = lipgloss.JoinHorizontal( + lipgloss.Top, + codePanel, + lipgloss.NewStyle().Width(1).Render(""), + button, + ) + } else { + codeBlock = lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.PlaceHorizontal(width, lipgloss.Right, button), + codePanel, + ) + } + renderedParts = append(renderedParts, codeBlock) + copyBindings = append(copyBindings, copyCodeButtonBinding{ID: nextCopyID, Code: code}) + nextCopyID++ + } } - rendered, err := a.markdownRenderer.Render(content, max(16, width-2)) - if err != nil { - return bodyStyle.Render(emptyMessageText) + if len(renderedParts) == 0 { + return bodyStyle.Render(emptyMessageText), nil } - return bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width))) + return lipgloss.JoinVertical(lipgloss.Left, renderedParts...), copyBindings } func normalizeBlockRightEdge(content string, maxWidth int) string { From 4cda6752de23fd5c9b111348dfcc9f7cb419258f Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 14:20:46 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix(tui):=20=E5=A4=8D=E5=88=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E7=BD=AE=E9=A1=B6=E5=B9=B6=E6=81=A2=E5=A4=8D=20markdo?= =?UTF-8?q?wn=20=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=9D=97=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/copy_code.go | 44 ++++++++++++++++------ internal/tui/copy_code_test.go | 6 ++- internal/tui/markdown_renderer.go | 17 +-------- internal/tui/markdown_renderer_test.go | 20 ---------- internal/tui/view.go | 52 ++++++++++++++------------ 5 files changed, 68 insertions(+), 71 deletions(-) diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go index 790c89c5..20e44663 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/copy_code.go @@ -24,8 +24,10 @@ const ( ) type markdownSegment struct { - Kind markdownSegmentKind - Text string + Kind markdownSegmentKind + Text string + Fenced string + Code string } var ( @@ -48,11 +50,15 @@ func splitMarkdownSegments(content string) []markdownSegment { continue } - code := extractCodeBlockContent(part) + fenced, code := parseCodeFence(part) if code == "" { continue } - segments = append(segments, markdownSegment{Kind: markdownSegmentCode, Text: code}) + segments = append(segments, markdownSegment{ + Kind: markdownSegmentCode, + Fenced: fenced, + Code: code, + }) } if len(segments) == 0 { return []markdownSegment{{Kind: markdownSegmentText, Text: content}} @@ -64,23 +70,37 @@ func extractFencedCodeBlocks(content string) []string { segments := splitMarkdownSegments(content) blocks := make([]string, 0, len(segments)) for _, segment := range segments { - if segment.Kind == markdownSegmentCode && strings.TrimSpace(segment.Text) != "" { - blocks = append(blocks, strings.TrimSpace(segment.Text)) + if segment.Kind == markdownSegmentCode && strings.TrimSpace(segment.Code) != "" { + blocks = append(blocks, strings.TrimSpace(segment.Code)) } } return blocks } -func extractCodeBlockContent(raw string) string { - code := strings.Trim(raw, "\n") +func parseCodeFence(raw string) (fenced string, code string) { + code = strings.Trim(raw, "\n") if code == "" { - return "" + return "", "" } lines := strings.Split(code, "\n") - if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { - code = strings.Join(lines[1:], "\n") + if len(lines) > 1 && isFenceLanguageCandidate(lines[0]) { + body := strings.Join(lines[1:], "\n") + body = strings.TrimSpace(body) + if body == "" { + return "", "" + } + return "```" + lines[0] + "\n" + body + "\n```", body + } + + code = strings.TrimSpace(code) + if code == "" { + return "", "" } - return strings.TrimSpace(code) + return "```\n" + code + "\n```", code +} + +func isFenceLanguageCandidate(line string) bool { + return !strings.Contains(line, " ") && !strings.Contains(line, "\t") } func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 88f0c480..6b1010d3 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -50,6 +50,10 @@ func TestRenderMessageBlockWithCopyAddsButtons(t *testing.T) { if !strings.Contains(rendered, "[Copy code #1]") { t.Fatalf("expected copy button in rendered message, got %q", rendered) } + plain := stripANSI(rendered) + if strings.Index(plain, "[Copy code #1]") > strings.Index(plain, "fmt.Println(1)") { + t.Fatalf("expected copy button to render above code block, got %q", plain) + } if len(bindings) != 1 || bindings[0].ID != 1 || bindings[0].Code != "fmt.Println(1)" { t.Fatalf("unexpected bindings: %+v", bindings) } @@ -112,7 +116,7 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { } if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + 1, + X: x + 60, Y: y + targetY, Button: tea.MouseButtonLeft, }); handled { diff --git a/internal/tui/markdown_renderer.go b/internal/tui/markdown_renderer.go index b3e245d3..17fcbeaf 100644 --- a/internal/tui/markdown_renderer.go +++ b/internal/tui/markdown_renderer.go @@ -55,10 +55,9 @@ func (r *glamourMarkdownRenderer) Render(content string, width int) (string, err if err != nil { return "", err } - rendered = markdownANSIPattern.ReplaceAllString(rendered, "") rendered = strings.TrimRight(rendered, "\n") - rendered = trimMarkdownTrailingWhitespace(rendered) - if strings.TrimSpace(rendered) == "" { + visible := markdownANSIPattern.ReplaceAllString(rendered, "") + if strings.TrimSpace(visible) == "" { rendered = emptyMessageText } @@ -99,15 +98,3 @@ func (r *glamourMarkdownRenderer) cacheResult(key string, value string) { r.cacheOrder = append(r.cacheOrder, key) r.cache[key] = value } - -func trimMarkdownTrailingWhitespace(input string) string { - if input == "" { - return input - } - - lines := strings.Split(input, "\n") - for i := range lines { - lines[i] = strings.TrimRight(lines[i], " \t") - } - return strings.Join(lines, "\n") -} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/markdown_renderer_test.go index 5abceae1..1dbc4f72 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/markdown_renderer_test.go @@ -97,23 +97,3 @@ func TestMarkdownRendererPreservesChineseText(t *testing.T) { t.Fatalf("expected chinese markdown content to be preserved, got %q", visible) } } - -func TestMarkdownRendererTrimsTrailingWhitespace(t *testing.T) { - rendererAny, err := newMarkdownRenderer() - if err != nil { - t.Fatalf("newMarkdownRenderer() error = %v", err) - } - renderer := rendererAny.(*glamourMarkdownRenderer) - - output, err := renderer.Render("nihao", 48) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - - visible := markdownTestANSIPattern.ReplaceAllString(output, "") - for _, line := range strings.Split(visible, "\n") { - if strings.HasSuffix(line, " ") || strings.HasSuffix(line, "\t") { - t.Fatalf("expected no trailing whitespace in line %q", line) - } - } -} diff --git a/internal/tui/view.go b/internal/tui/view.go index 9e29a404..b05d0729 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -263,7 +263,15 @@ func (a App) renderMessageBlockWithCopy(message provider.Message, width int, sta content = emptyMessageText } - contentBlock, copyButtons := a.renderMessageContentWithCopy(content, maxMessageWidth-2, bodyStyle, startCopyID) + var ( + contentBlock string + copyButtons []copyCodeButtonBinding + ) + if message.Role == roleUser { + contentBlock = bodyStyle.Render(wrapPlain(content, max(16, maxMessageWidth-2))) + } else { + contentBlock, copyButtons = a.renderMessageContentWithCopy(content, maxMessageWidth-2, bodyStyle, startCopyID) + } parts := []string{tagStyle.Render(tag), contentBlock} block := lipgloss.JoinVertical(blockAlign, parts...) @@ -361,6 +369,7 @@ func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle l if err != nil { return bodyStyle.Render(emptyMessageText), nil } + rendered = trimRenderedTrailingWhitespace(rendered) return bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width))), nil } @@ -378,36 +387,25 @@ func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle l if err != nil { continue } + rendered = trimRenderedTrailingWhitespace(rendered) renderedParts = append(renderedParts, bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width)))) case markdownSegmentCode: - code := strings.TrimSpace(segment.Text) + code := strings.TrimSpace(segment.Code) if code == "" { continue } buttonText := fmt.Sprintf(copyCodeButton, nextCopyID) button := a.styles.codeCopyButton.Render(buttonText) - buttonWidth := lipgloss.Width(button) - - codeAreaWidth := max(16, width-buttonWidth-2) - codeTextWidth := max(8, codeAreaWidth-4) - renderedCode := wrapCodeBlock(code, codeTextWidth) - codePanel := a.styles.codeBlock.Width(codeAreaWidth).Render(a.styles.codeText.Width(codeTextWidth).Render(renderedCode)) - - var codeBlock string - if codeAreaWidth+1+buttonWidth <= width { - codeBlock = lipgloss.JoinHorizontal( - lipgloss.Top, - codePanel, - lipgloss.NewStyle().Width(1).Render(""), - button, - ) - } else { - codeBlock = lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.PlaceHorizontal(width, lipgloss.Right, button), - codePanel, - ) + renderedCode, err := a.markdownRenderer.Render(segment.Fenced, max(16, width-2)) + if err != nil { + codeTextWidth := max(8, width-4) + renderedCode = a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(codeTextWidth).Render(wrapCodeBlock(code, codeTextWidth))) } + codeBlock := lipgloss.JoinVertical( + lipgloss.Left, + button, + trimRenderedTrailingWhitespace(renderedCode), + ) renderedParts = append(renderedParts, codeBlock) copyBindings = append(copyBindings, copyCodeButtonBinding{ID: nextCopyID, Code: code}) nextCopyID++ @@ -440,6 +438,14 @@ func normalizeBlockRightEdge(content string, maxWidth int) string { return strings.Join(normalized, "\n") } +func trimRenderedTrailingWhitespace(content string) string { + lines := strings.Split(content, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " \t") + } + return strings.Join(lines, "\n") +} + func (a App) statusBadge(text string) string { lower := strings.ToLower(text) switch { From 1bdd8ec5427f5ef7d9da37f8e8476b8d30a6b423 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 14:32:49 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(tui):=20=E4=BB=85=E5=9C=A8=E5=B7=A6?= =?UTF-8?q?=E9=94=AE=E7=82=B9=E5=87=BB=E6=97=B6=E8=A7=A6=E5=8F=91=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/copy_code_test.go | 10 ++++++++++ internal/tui/update.go | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 6b1010d3..70d0ac39 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -122,6 +122,16 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { }); handled { t.Fatalf("expected click outside copy button text to be ignored") } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + targetX + 1, + Y: y + targetY, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionMotion, + Type: tea.MouseMotion, + }); handled { + t.Fatalf("expected hover/motion over copy button to be ignored") + } } func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { diff --git a/internal/tui/update.go b/internal/tui/update.go index f8f8c893..3ed937bf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -572,14 +572,14 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { return false } - switch msg.Button { - case tea.MouseButtonWheelUp: + switch { + case msg.Button == tea.MouseButtonWheelUp && msg.Action == tea.MouseActionPress: a.transcript.LineUp(mouseWheelStepLines) return true - case tea.MouseButtonWheelDown: + case msg.Button == tea.MouseButtonWheelDown && msg.Action == tea.MouseActionPress: a.transcript.LineDown(mouseWheelStepLines) return true - case tea.MouseButtonLeft: + case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: return a.handleTranscriptCopyClick(msg) default: return false From 12776130e63e67d0c056abde2debca9f0b316ad7 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 14:45:32 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix(tui):=20=E6=94=AF=E6=8C=81=E7=BC=A9?= =?UTF-8?q?=E8=BF=9B=E4=BB=A3=E7=A0=81=E5=9D=97=E6=98=BE=E7=A4=BA=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/copy_code.go | 96 ++++++++++++++++++++++++++++++++-- internal/tui/copy_code_test.go | 29 ++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go index 20e44663..08f8e82d 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/copy_code.go @@ -36,11 +36,11 @@ var ( ) func splitMarkdownSegments(content string) []markdownSegment { - parts := strings.Split(content, "```") - if len(parts) == 1 { - return []markdownSegment{{Kind: markdownSegmentText, Text: content}} + if !strings.Contains(content, "```") { + return splitIndentedCodeSegments(content) } + parts := strings.Split(content, "```") segments := make([]markdownSegment, 0, len(parts)) for i, part := range parts { if i%2 == 0 { @@ -66,6 +66,77 @@ func splitMarkdownSegments(content string) []markdownSegment { return segments } +func splitIndentedCodeSegments(content string) []markdownSegment { + lines := strings.Split(content, "\n") + segments := make([]markdownSegment, 0, 4) + textLines := make([]string, 0, len(lines)) + codeLines := make([]string, 0, len(lines)) + inCode := false + + flushText := func() { + if len(textLines) == 0 { + return + } + segments = append(segments, markdownSegment{ + Kind: markdownSegmentText, + Text: strings.Join(textLines, "\n"), + }) + textLines = textLines[:0] + } + flushCode := func() { + if len(codeLines) == 0 { + return + } + code := strings.Join(codeLines, "\n") + code = strings.TrimSpace(code) + if code == "" { + codeLines = codeLines[:0] + return + } + segments = append(segments, markdownSegment{ + Kind: markdownSegmentCode, + Fenced: "```\n" + code + "\n```", + Code: code, + }) + codeLines = codeLines[:0] + } + + for _, line := range lines { + indented := isIndentedCodeLine(line) + if inCode { + if indented { + codeLines = append(codeLines, trimCodeIndent(line)) + continue + } + if strings.TrimSpace(line) == "" { + codeLines = append(codeLines, "") + continue + } + flushCode() + inCode = false + } + + if indented { + flushText() + inCode = true + codeLines = append(codeLines, trimCodeIndent(line)) + continue + } + + textLines = append(textLines, line) + } + + if inCode { + flushCode() + } + flushText() + + if len(segments) == 0 { + return []markdownSegment{{Kind: markdownSegmentText, Text: content}} + } + return segments +} + func extractFencedCodeBlocks(content string) []string { segments := splitMarkdownSegments(content) blocks := make([]string, 0, len(segments)) @@ -91,6 +162,11 @@ func parseCodeFence(raw string) (fenced string, code string) { } return "```" + lines[0] + "\n" + body + "\n```", body } + if len(lines) == 1 && isFenceLanguageCandidate(lines[0]) { + // Streaming may temporarily contain only the language marker (e.g. ```go). + // Skip until body content arrives. + return "", "" + } code = strings.TrimSpace(code) if code == "" { @@ -103,6 +179,20 @@ func isFenceLanguageCandidate(line string) bool { return !strings.Contains(line, " ") && !strings.Contains(line, "\t") } +func isIndentedCodeLine(line string) bool { + return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") +} + +func trimCodeIndent(line string) string { + if strings.HasPrefix(line, "\t") { + return strings.TrimPrefix(line, "\t") + } + if strings.HasPrefix(line, " ") { + return line[4:] + } + return line +} + func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { a.codeCopyBlocks = make(map[int]string, len(bindings)) for _, binding := range bindings { diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 70d0ac39..40bd3d1a 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -24,6 +24,17 @@ func TestExtractFencedCodeBlocks(t *testing.T) { } } +func TestExtractFencedCodeBlocksFromIndentedMarkdown(t *testing.T) { + content := "说明:\n\n package main\n import \"fmt\"\n\n结尾。" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 1 { + t.Fatalf("expected 1 code block from indented markdown, got %d", len(blocks)) + } + if !strings.Contains(blocks[0], "package main") || !strings.Contains(blocks[0], "import \"fmt\"") { + t.Fatalf("expected extracted indented code block, got %q", blocks[0]) + } +} + func TestParseCopyCodeButtonID(t *testing.T) { id, startCol, endCol, ok := parseCopyCodeButton("[Copy code #12]") if !ok || id != 12 { @@ -59,6 +70,24 @@ func TestRenderMessageBlockWithCopyAddsButtons(t *testing.T) { } } +func TestRenderMessageBlockWithCopyAddsButtonsForIndentedCode(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + content := "说明:\n\n package main\n import \"fmt\"" + rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) + if !strings.Contains(stripANSI(rendered), "[Copy code #1]") { + t.Fatalf("expected copy button for indented markdown code, got %q", rendered) + } + if len(bindings) != 1 || !strings.Contains(bindings[0].Code, "package main") { + t.Fatalf("unexpected bindings for indented markdown code: %+v", bindings) + } +} + func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() From 2bf272190c2a3c33b3610cd9e5d3dd6ffee02942 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 15:13:06 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(tui):=20=E5=A4=8D=E5=88=B6=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=82=B9=E5=87=BB=E9=87=8A=E6=94=BE=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=B9=B6=E5=85=BC=E5=AE=B9=E7=BB=88=E7=AB=AF=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 1 + internal/tui/copy_code.go | 11 +++++++---- internal/tui/copy_code_test.go | 28 +++++++++++++++++++++++++--- internal/tui/update.go | 24 +++++++++++++++++++++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 6284a09c..53d0f50f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -31,6 +31,7 @@ type App struct { input textarea.Model markdownRenderer markdownContentRenderer codeCopyBlocks map[int]string + pendingCopyID int activeMessages []provider.Message activities []activityEntry fileCandidates []string diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go index 08f8e82d..4ad27ad6 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/copy_code.go @@ -219,20 +219,23 @@ func parseCopyCodeButton(line string) (id int, startCol int, endCol int, ok bool return id, startCol, endCol, true } -func (a *App) handleTranscriptCopyClick(msg tea.MouseMsg) bool { +func (a *App) copyButtonIDAtMouse(msg tea.MouseMsg) (int, bool) { line, relativeX, ok := a.transcriptLineAtMouse(msg) if !ok { - return false + return 0, false } buttonID, startCol, endCol, ok := parseCopyCodeButton(line) if !ok { - return false + return 0, false } if relativeX < startCol || relativeX >= endCol { - return false + return 0, false } + return buttonID, true +} +func (a *App) copyCodeBlockByID(buttonID int) bool { code, ok := a.codeCopyBlocks[buttonID] if !ok { a.state.ExecutionError = statusCodeCopyError diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 40bd3d1a..cf4e0f92 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -134,7 +134,19 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { Y: y + targetY, Button: tea.MouseButtonLeft, }); !handled { - t.Fatalf("expected mouse click on copy button to be handled") + t.Fatalf("expected mouse press on copy button to be handled") + } + if copied != "" { + t.Fatalf("expected press phase not to copy yet, got %q", copied) + } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + targetX + 1, + Y: y + targetY, + Action: tea.MouseActionRelease, + Type: tea.MouseRelease, + }); !handled { + t.Fatalf("expected mouse release on copy button to be handled") } if copied != "fmt.Println(1)" { @@ -148,8 +160,10 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { X: x + 60, Y: y + targetY, Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + Type: tea.MouseRelease, }); handled { - t.Fatalf("expected click outside copy button text to be ignored") + t.Fatalf("expected release outside copy button text to be ignored") } if handled := app.handleTranscriptMouse(tea.MouseMsg{ @@ -207,7 +221,15 @@ func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { Y: y + targetY, Button: tea.MouseButtonLeft, }); !handled { - t.Fatalf("expected mouse click on copy button to be handled") + t.Fatalf("expected mouse press on copy button to be handled") + } + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + targetX + 1, + Y: y + targetY, + Action: tea.MouseActionRelease, + Type: tea.MouseRelease, + }); !handled { + t.Fatalf("expected mouse release on copy button to be handled") } if app.state.StatusText != statusCodeCopyError || app.state.ExecutionError == "" { diff --git a/internal/tui/update.go b/internal/tui/update.go index 3ed937bf..afda66c8 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -569,6 +569,9 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { if !a.isMouseWithinTranscript(msg) { + if msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease { + a.pendingCopyID = 0 + } return false } @@ -579,8 +582,27 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { case msg.Button == tea.MouseButtonWheelDown && msg.Action == tea.MouseActionPress: a.transcript.LineDown(mouseWheelStepLines) return true + case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: + return false case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: - return a.handleTranscriptCopyClick(msg) + if buttonID, ok := a.copyButtonIDAtMouse(msg); ok { + a.pendingCopyID = buttonID + return true + } + a.pendingCopyID = 0 + return false + case msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease: + defer func() { a.pendingCopyID = 0 }() + + buttonID, ok := a.copyButtonIDAtMouse(msg) + if !ok { + return false + } + + if a.pendingCopyID != 0 && a.pendingCopyID != buttonID { + return false + } + return a.copyCodeBlockByID(buttonID) default: return false } From 606ede5d16ac6cd5ff96c69530e8b124fe31ba21 Mon Sep 17 00:00:00 2001 From: creatang Date: Fri, 3 Apr 2026 13:54:21 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=8C=BA=E7=B2=98=E8=B4=B4=E5=8F=91=E9=80=81=E4=B8=8E?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 10 +- internal/tui/copy_code.go | 116 +++++++----- internal/tui/copy_code_test.go | 29 +++ internal/tui/update.go | 333 +++++++++++++++++++++++++++------ internal/tui/update_test.go | 314 +++++++++++++++++++++++++++++++ internal/tui/view.go | 2 +- 6 files changed, 698 insertions(+), 106 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 53d0f50f..c34edfac 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" @@ -32,6 +33,12 @@ type App struct { markdownRenderer markdownContentRenderer codeCopyBlocks map[int]string pendingCopyID int + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool activeMessages []provider.Message activities []activityEntry fileCandidates []string @@ -74,7 +81,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.CharLimit = 24000 + input.CharLimit = 0 input.ShowLineNumbers = false input.SetPromptFunc(composerPromptWidth, func(line int) string { return "> " @@ -124,6 +131,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime input: input, markdownRenderer: markdownRenderer, codeCopyBlocks: make(map[int]string), + nowFn: time.Now, focus: panelInput, width: 128, height: 40, diff --git a/internal/tui/copy_code.go b/internal/tui/copy_code.go index 4ad27ad6..b92b87fa 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/copy_code.go @@ -40,30 +40,80 @@ func splitMarkdownSegments(content string) []markdownSegment { return splitIndentedCodeSegments(content) } - parts := strings.Split(content, "```") - segments := make([]markdownSegment, 0, len(parts)) - for i, part := range parts { - if i%2 == 0 { - if part != "" { - segments = append(segments, markdownSegment{Kind: markdownSegmentText, Text: part}) - } - continue - } + lines := strings.Split(content, "\n") + segments := make([]markdownSegment, 0, 8) + textLines := make([]string, 0, len(lines)) + codeLines := make([]string, 0, len(lines)) + inFence := false + fenceInfo := "" + sawFence := false - fenced, code := parseCodeFence(part) - if code == "" { - continue + flushText := func() { + if len(textLines) == 0 { + return + } + segments = append(segments, markdownSegment{ + Kind: markdownSegmentText, + Text: strings.Join(textLines, "\n"), + }) + textLines = textLines[:0] + } + flushCode := func() { + if len(codeLines) == 0 { + codeLines = codeLines[:0] + return + } + code := strings.Join(codeLines, "\n") + code = strings.TrimRight(code, "\n") + if strings.TrimSpace(code) == "" { + codeLines = codeLines[:0] + return + } + fenced := "```" + if fenceInfo != "" { + fenced += fenceInfo } + fenced += "\n" + code + "\n```" segments = append(segments, markdownSegment{ Kind: markdownSegmentCode, Fenced: fenced, Code: code, }) + codeLines = codeLines[:0] } - if len(segments) == 0 { - return []markdownSegment{{Kind: markdownSegmentText, Text: content}} + + for _, line := range lines { + if !inFence { + if info, ok := parseFenceOpenLine(line); ok { + sawFence = true + flushText() + inFence = true + fenceInfo = info + continue + } + textLines = append(textLines, line) + continue + } + + if isFenceCloseLine(line) { + flushCode() + inFence = false + fenceInfo = "" + continue + } + codeLines = append(codeLines, line) } - return segments + + if inFence { + flushCode() + } + flushText() + + if sawFence && len(segments) > 0 { + return segments + } + + return splitIndentedCodeSegments(content) } func splitIndentedCodeSegments(content string) []markdownSegment { @@ -142,41 +192,23 @@ func extractFencedCodeBlocks(content string) []string { blocks := make([]string, 0, len(segments)) for _, segment := range segments { if segment.Kind == markdownSegmentCode && strings.TrimSpace(segment.Code) != "" { - blocks = append(blocks, strings.TrimSpace(segment.Code)) + blocks = append(blocks, segment.Code) } } return blocks } -func parseCodeFence(raw string) (fenced string, code string) { - code = strings.Trim(raw, "\n") - if code == "" { - return "", "" - } - lines := strings.Split(code, "\n") - if len(lines) > 1 && isFenceLanguageCandidate(lines[0]) { - body := strings.Join(lines[1:], "\n") - body = strings.TrimSpace(body) - if body == "" { - return "", "" - } - return "```" + lines[0] + "\n" + body + "\n```", body - } - if len(lines) == 1 && isFenceLanguageCandidate(lines[0]) { - // Streaming may temporarily contain only the language marker (e.g. ```go). - // Skip until body content arrives. - return "", "" - } - - code = strings.TrimSpace(code) - if code == "" { - return "", "" +func parseFenceOpenLine(line string) (string, bool) { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, "```") { + return "", false } - return "```\n" + code + "\n```", code + return strings.TrimSpace(strings.TrimPrefix(trimmed, "```")), true } -func isFenceLanguageCandidate(line string) bool { - return !strings.Contains(line, " ") && !strings.Contains(line, "\t") +func isFenceCloseLine(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + return strings.TrimSpace(trimmed) == "```" } func isIndentedCodeLine(line string) bool { diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index cf4e0f92..4c77ca5d 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -24,6 +24,17 @@ func TestExtractFencedCodeBlocks(t *testing.T) { } } +func TestExtractFencedCodeBlocksWithoutLanguageKeepsFirstLine(t *testing.T) { + content := "before\n```\nSELECT\nFROM users;\n```\nafter" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 1 { + t.Fatalf("expected 1 code block, got %d", len(blocks)) + } + if !strings.Contains(blocks[0], "SELECT") || !strings.Contains(blocks[0], "FROM users;") { + t.Fatalf("expected full code block content, got %q", blocks[0]) + } +} + func TestExtractFencedCodeBlocksFromIndentedMarkdown(t *testing.T) { content := "说明:\n\n package main\n import \"fmt\"\n\n结尾。" blocks := extractFencedCodeBlocks(content) @@ -70,6 +81,24 @@ func TestRenderMessageBlockWithCopyAddsButtons(t *testing.T) { } } +func TestRenderMessageBlockWithCopyPreservesCodeIndentation(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + content := "```go\nfunc main() {\n\tif true {\n\t\tprintln(\"ok\")\n\t}\n}\n```" + _, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) + if len(bindings) != 1 { + t.Fatalf("expected one copy binding, got %+v", bindings) + } + if !strings.Contains(bindings[0].Code, "\tif true {") || !strings.Contains(bindings[0].Code, "\t\tprintln(\"ok\")") { + t.Fatalf("expected indentation preserved in copied code, got %q", bindings[0].Code) + } +} + func TestRenderMessageBlockWithCopyAddsButtonsForIndentedCode(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/update.go b/internal/tui/update.go index afda66c8..ce938230 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -32,6 +32,10 @@ const ( composerMaxHeight = 5 composerPromptWidth = 2 mouseWheelStepLines = 3 + pasteBurstWindow = 120 * time.Millisecond + pasteEnterGuard = 180 * time.Millisecond + pasteSessionGuard = 5 * time.Second + pasteBurstThreshold = 12 ) func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -127,6 +131,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.handleTranscriptMouse(typed) { return a, tea.Batch(cmds...) } + if a.handleInputMouse(typed) { + return a, tea.Batch(cmds...) + } case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { return a, tea.Quit @@ -146,8 +153,14 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } - if a.focus == panelInput && key.Matches(typed, a.keys.NextPanel) && a.applyTopFileSuggestion() { - return a, tea.Batch(cmds...) + if a.focus == panelInput && key.Matches(typed, a.keys.NextPanel) { + if a.applyTopFileSuggestion() { + return a, tea.Batch(cmds...) + } + if a.shouldHandleTabAsInput(typed) { + tabMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\t'}, Paste: typed.Paste} + return a.updateInputPanel(tabMsg, tabMsg, cmds) + } } if key.Matches(typed, a.keys.NextPanel) { a.focusNext() @@ -196,91 +209,197 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + now := a.now() + effectiveTyped := typed + if key.Matches(typed, a.keys.Send) { - input := strings.TrimSpace(a.input.Value()) - if input == "" || a.state.IsAgentRunning { - return a, tea.Batch(cmds...) - } + if a.shouldTreatEnterAsNewline(typed, now) { + a.growComposerForNewline() + msg = tea.KeyMsg{Type: tea.KeyEnter} + effectiveTyped = tea.KeyMsg{Type: tea.KeyEnter, Paste: true} + } else { + input := strings.TrimSpace(a.input.Value()) + if input == "" || a.state.IsAgentRunning { + return a, tea.Batch(cmds...) + } - a.input.Reset() - a.state.InputText = "" - a.resizeComponents() + a.input.Reset() + a.state.InputText = "" + a.resizeComponents() + a.resetPasteHeuristics() - if handled, cmd := a.handleImmediateSlashCommand(input); handled { - if cmd != nil { - cmds = append(cmds, cmd) + if handled, cmd := a.handleImmediateSlashCommand(input); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) } - return a, tea.Batch(cmds...) - } - switch strings.ToLower(input) { - case slashCommandProvider: - if err := a.refreshProviderPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + switch strings.ToLower(input) { + case slashCommandProvider: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.openProviderPicker() return a, tea.Batch(cmds...) - } - a.openProviderPicker() - return a, tea.Batch(cmds...) - case slashCommandModelPick: - if err := a.refreshModelPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh models", err.Error(), true) + case slashCommandModelPick: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh models", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.openModelPicker() return a, tea.Batch(cmds...) } - a.openModelPicker() - return a, tea.Batch(cmds...) - } - if strings.HasPrefix(input, slashPrefix) { - a.state.StatusText = statusApplyingCommand - cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), input)) - return a, tea.Batch(cmds...) - } - if isWorkspaceCommandInput(input) { - command, err := extractWorkspaceCommand(input) - if err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("command", "Invalid workspace command", err.Error(), true) + if strings.HasPrefix(input, slashPrefix) { + a.state.StatusText = statusApplyingCommand + cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), input)) + return a, tea.Batch(cmds...) + } + if isWorkspaceCommandInput(input) { + command, err := extractWorkspaceCommand(input) + if err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("command", "Invalid workspace command", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.activities = nil + a.state.StatusText = statusRunningCommand + a.state.ExecutionError = "" + a.appendActivity("command", "Running command", command, false) + cmds = append(cmds, runWorkspaceCommand(a.configManager, input)) return a, tea.Batch(cmds...) } + a.activities = nil - a.state.StatusText = statusRunningCommand + a.state.IsAgentRunning = true + a.state.StreamingReply = false a.state.ExecutionError = "" - a.appendActivity("command", "Running command", command, false) - cmds = append(cmds, runWorkspaceCommand(a.configManager, input)) + a.state.StatusText = statusThinking + a.state.CurrentTool = "" + a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) + a.rebuildTranscript() + cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, input)) return a, tea.Batch(cmds...) } - - a.activities = nil - a.state.IsAgentRunning = true - a.state.StreamingReply = false - a.state.ExecutionError = "" - a.state.StatusText = statusThinking - a.state.CurrentTool = "" - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) - a.rebuildTranscript() - cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, input)) - return a, tea.Batch(cmds...) } if key.Matches(typed, a.keys.Newline) { a.growComposerForNewline() msg = tea.KeyMsg{Type: tea.KeyEnter} + effectiveTyped = tea.KeyMsg{Type: tea.KeyEnter} } + before := a.input.Value() var cmd tea.Cmd a.input, cmd = a.input.Update(msg) a.state.InputText = a.input.Value() + a.noteInputEdit(before, a.state.InputText, effectiveTyped, now) a.normalizeComposerHeight() a.resizeComposerLayout() cmds = append(cmds, cmd) return a, tea.Batch(cmds...) } +func (a App) now() time.Time { + if a.nowFn == nil { + return time.Now() + } + return a.nowFn() +} + +func (a *App) shouldTreatEnterAsNewline(typed tea.KeyMsg, now time.Time) bool { + if !key.Matches(typed, a.keys.Send) || a.state.IsAgentRunning { + return false + } + if typed.Paste { + a.pasteMode = true + a.lastPasteLikeAt = now + return true + } + if a.pasteMode && + !a.lastPasteLikeAt.IsZero() && + !a.lastInputEditAt.IsZero() && + now.Sub(a.lastPasteLikeAt) <= pasteSessionGuard && + now.Sub(a.lastInputEditAt) <= pasteEnterGuard { + return true + } + if a.pasteMode && !a.lastPasteLikeAt.IsZero() && now.Sub(a.lastPasteLikeAt) > pasteSessionGuard { + a.pasteMode = false + } + if a.lastPasteLikeAt.IsZero() { + return false + } + return now.Sub(a.lastPasteLikeAt) <= pasteEnterGuard +} + +func (a *App) noteInputEdit(before string, after string, typed tea.KeyMsg, now time.Time) { + if before == after { + return + } + + prevEditAt := a.lastInputEditAt + a.lastInputEditAt = now + + if key.Matches(typed, a.keys.Newline) { + a.inputBurstStart = time.Time{} + a.inputBurstCount = 0 + return + } + + pasteLike := typed.Paste + + switch typed.Type { + case tea.KeyRunes: + runeCount := len(typed.Runes) + if runeCount > 1 { + pasteLike = true + } + if strings.ContainsRune(string(typed.Runes), '\n') || strings.ContainsRune(string(typed.Runes), '\r') { + pasteLike = true + } + if runeCount > 0 { + if prevEditAt.IsZero() || now.Sub(prevEditAt) > pasteBurstWindow || a.inputBurstCount == 0 { + a.inputBurstStart = now + a.inputBurstCount = runeCount + } else { + a.inputBurstCount += runeCount + } + if a.inputBurstCount >= pasteBurstThreshold { + pasteLike = true + } + } + case tea.KeyEnter: + if typed.Paste && strings.Count(after, "\n") > strings.Count(before, "\n") { + pasteLike = true + } + a.inputBurstStart = time.Time{} + a.inputBurstCount = 0 + default: + a.inputBurstStart = time.Time{} + a.inputBurstCount = 0 + } + + if pasteLike { + a.lastPasteLikeAt = now + a.pasteMode = true + } +} + +func (a *App) resetPasteHeuristics() { + a.lastInputEditAt = time.Time{} + a.lastPasteLikeAt = time.Time{} + a.inputBurstStart = time.Time{} + a.inputBurstCount = 0 + a.pasteMode = false +} + func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.FocusInput): @@ -568,6 +687,21 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { } func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { + switch { + case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): + if !a.isMouseWithinTranscript(msg) { + return false + } + a.transcript.LineUp(mouseWheelStepLines) + return true + case msg.Button == tea.MouseButtonWheelDown && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelDown): + if !a.isMouseWithinTranscript(msg) { + return false + } + a.transcript.LineDown(mouseWheelStepLines) + return true + } + if !a.isMouseWithinTranscript(msg) { if msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease { a.pendingCopyID = 0 @@ -576,12 +710,6 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { } switch { - case msg.Button == tea.MouseButtonWheelUp && msg.Action == tea.MouseActionPress: - a.transcript.LineUp(mouseWheelStepLines) - return true - case msg.Button == tea.MouseButtonWheelDown && msg.Action == tea.MouseActionPress: - a.transcript.LineDown(mouseWheelStepLines) - return true case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: return false case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: @@ -634,6 +762,87 @@ func (a App) transcriptBounds() (int, int, int, int) { return streamX, streamY, lay.rightWidth, a.transcript.Height } +func (a App) isMouseWithinInput(msg tea.MouseMsg) bool { + x, y, width, height := a.inputBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a App) inputBounds() (int, int, int, int) { + lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + bodyY := contentY + headerHeight + + streamX := contentX + streamY := bodyY + if lay.stacked { + streamY += lay.sidebarHeight + } else { + streamX += lay.sidebarWidth + lay.bodyGap + } + + inputY := streamY + a.transcript.Height + a.activityPreviewHeight() + a.commandMenuHeight(max(24, lay.rightWidth)) + inputHeight := lipgloss.Height(a.renderPrompt(max(24, lay.rightWidth))) + return streamX, inputY, lay.rightWidth, inputHeight +} + +func (a *App) handleInputMouse(msg tea.MouseMsg) bool { + if !a.isMouseWithinInput(msg) { + return false + } + if a.state.ActivePicker != pickerNone { + return false + } + + switch { + case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): + a.scrollInputPage(-1) + return true + case msg.Button == tea.MouseButtonWheelDown && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelDown): + a.scrollInputPage(1) + return true + default: + return false + } +} + +func (a *App) scrollInputPage(direction int) { + if direction == 0 { + return + } + if a.focus != panelInput { + a.focus = panelInput + a.applyFocus() + } + + step := max(1, a.input.Height()-1) + keyType := tea.KeyUp + if direction > 0 { + keyType = tea.KeyDown + } + + for i := 0; i < step; i++ { + var cmd tea.Cmd + a.input, cmd = a.input.Update(tea.KeyMsg{Type: keyType}) + _ = cmd + } + a.state.InputText = a.input.Value() +} + +func (a App) shouldHandleTabAsInput(typed tea.KeyMsg) bool { + if a.focus != panelInput || a.state.ActivePicker != pickerNone || typed.Type != tea.KeyTab { + return false + } + if typed.Paste || a.pasteMode { + return true + } + return strings.TrimSpace(a.input.Value()) != "" +} + func (a *App) focusNext() { order := []panel{panelSessions, panelTranscript, panelInput} current := 0 diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 7eef3db5..3529bffb 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -703,6 +703,29 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "tab in non-empty input inserts indentation instead of switching panel", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.focus = panelInput + app.applyFocus() + app.input.SetValue("func main() {\n") + app.state.InputText = app.input.Value() + app.resizeComponents() + }, + msg: tea.KeyMsg{Type: tea.KeyTab}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.focus != panelInput { + t.Fatalf("expected focus to stay in input, got %v", app.focus) + } + if !strings.Contains(app.state.InputText, "func main() {") { + t.Fatalf("expected existing code to be preserved, got %q", app.state.InputText) + } + if !strings.HasSuffix(app.state.InputText, "\n\t") && !strings.HasSuffix(app.state.InputText, "\n ") { + t.Fatalf("expected tab indentation at the end, got %q", app.state.InputText) + } + }, + }, { name: "previous panel moves focus backward", msg: tea.KeyMsg{Type: tea.KeyShiftTab}, @@ -950,6 +973,215 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } } +func TestAppUpdatePasteEnterGuard(t *testing.T) { + t.Run("paste-like burst keeps enter as newline", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + app.nowFn = func() time.Time { return now } + + for _, r := range []rune("function_name") { + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + app = model.(App) + _ = collectTeaMessages(cmd) + now = now.Add(15 * time.Millisecond) + } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 0 { + t.Fatalf("expected enter to stay local during paste-like burst, got %+v", runtime.runInputs) + } + if app.state.InputText != "function_name\n" { + t.Fatalf("expected enter to insert newline, got %q", app.state.InputText) + } + if app.state.IsAgentRunning { + t.Fatalf("expected agent to remain idle") + } + }) + + t.Run("normal typing enter still sends", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + app.nowFn = func() time.Time { return now } + + for _, r := range []rune("hello") { + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + app = model.(App) + _ = collectTeaMessages(cmd) + now = now.Add(300 * time.Millisecond) + } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + msgs := collectTeaMessages(cmd) + + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { + t.Fatalf("expected enter to send normal input once, got %+v", runtime.runInputs) + } + if app.state.InputText != "" { + t.Fatalf("expected input to reset after send, got %q", app.state.InputText) + } + for _, msg := range msgs { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + }) + + t.Run("explicit paste enter inserts newline", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.input.SetValue("before") + app.state.InputText = "before" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter, Paste: true}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 0 { + t.Fatalf("expected paste enter not to send, got %+v", runtime.runInputs) + } + if app.state.InputText != "before\n" { + t.Fatalf("expected paste enter to insert newline, got %q", app.state.InputText) + } + }) + + t.Run("segmented long paste keeps enter as newline across chunks", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + app.nowFn = func() time.Time { return now } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("package main")}) + app = model.(App) + _ = collectTeaMessages(cmd) + + now = now.Add(80 * time.Millisecond) + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + now = now.Add(80 * time.Millisecond) + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("func main() {}")}) + app = model.(App) + _ = collectTeaMessages(cmd) + + now = now.Add(80 * time.Millisecond) + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 0 { + t.Fatalf("expected segmented paste not to trigger send, got %+v", runtime.runInputs) + } + if app.state.InputText != "package main\nfunc main() {}\n" { + t.Fatalf("expected multiline pasted content, got %q", app.state.InputText) + } + }) + + t.Run("long gap after paste-like input allows immediate send", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + app.nowFn = func() time.Time { return now } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1")}) + app = model.(App) + _ = collectTeaMessages(cmd) + + now = now.Add(2 * time.Second) + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1" { + t.Fatalf("expected enter to send after long gap, got %+v", runtime.runInputs) + } + }) + + t.Run("enter sends after paste session expires", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + app.nowFn = func() time.Time { return now } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1\nline2")}) + app = model.(App) + _ = collectTeaMessages(cmd) + + now = now.Add(pasteSessionGuard + 100*time.Millisecond) + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1\nline2" { + t.Fatalf("expected send after paste session expiry, got %+v", runtime.runInputs) + } + }) + + t.Run("enter sends right after tab indentation in normal input", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.input.SetValue("hello") + app.state.InputText = app.input.Value() + app.focus = panelInput + app.applyFocus() + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyTab}) + app = model.(App) + _ = collectTeaMessages(cmd) + if !strings.Contains(app.state.InputText, "hello") { + t.Fatalf("expected tab to keep current input, got %q", app.state.InputText) + } + + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + if len(runtime.runInputs) != 1 { + t.Fatalf("expected enter to send after tab indentation, got %+v", runtime.runInputs) + } + }) +} + func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { manager := newTestConfigManager(t) if err := manager.Update(context.Background(), func(cfg *config.Config) error { @@ -1460,6 +1692,88 @@ func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { } } +func TestInputMouseWheelScrollsComposer(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.width = 128 + app.height = 40 + app.input.SetValue(strings.Join([]string{ + "line1", "line2", "line3", "line4", "line5", + "line6", "line7", "line8", "line9", "line10", + "line11", "line12", + }, "\n")) + app.state.InputText = app.input.Value() + app.resizeComponents() + app.focus = panelTranscript + app.applyFocus() + + initialLine := app.input.Line() + if initialLine < 1 { + t.Fatalf("expected cursor line to be >=1 for multiline input, got %d", initialLine) + } + pageStep := max(1, app.input.Height()-1) + + x, y, _, _ := app.inputBounds() + model, cmd := app.Update(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonWheelUp, + Type: tea.MouseWheelUp, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.focus != panelInput { + t.Fatalf("expected input wheel to focus input panel, got %v", app.focus) + } + if initialLine-app.input.Line() < pageStep-1 { + t.Fatalf("expected wheel up in input to page-scroll by ~%d lines, got from %d to %d", pageStep, initialLine, app.input.Line()) + } + + lineAfterUp := app.input.Line() + model, cmd = app.Update(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonWheelDown, + Type: tea.MouseWheelDown, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.input.Line()-lineAfterUp < pageStep-1 { + t.Fatalf("expected wheel down in input to page-scroll by ~%d lines, got from %d to %d", pageStep, lineAfterUp, app.input.Line()) + } + + lineBeforeOutside := app.input.Line() + model, cmd = app.Update(tea.MouseMsg{ + X: 0, + Y: 0, + Button: tea.MouseButtonWheelUp, + Type: tea.MouseWheelUp, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.input.Line() != lineBeforeOutside { + t.Fatalf("expected wheel outside input to be ignored, got line=%d", app.input.Line()) + } +} + +func TestInputCharLimitIsUnlimited(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if app.input.CharLimit != 0 { + t.Fatalf("expected unlimited input char limit, got %d", app.input.CharLimit) + } +} + func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index b05d0729..5b14caf9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -390,7 +390,7 @@ func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle l rendered = trimRenderedTrailingWhitespace(rendered) renderedParts = append(renderedParts, bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width)))) case markdownSegmentCode: - code := strings.TrimSpace(segment.Code) + code := strings.TrimRight(segment.Code, "\n") if code == "" { continue }