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/app/bootstrap.go b/internal/app/bootstrap.go index 1ccd1462..9f12c635 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -19,7 +19,24 @@ 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() + loader := config.NewLoader("", builtin.DefaultConfig()) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 1743a0a6..5937b73d 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" @@ -153,6 +154,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_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_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/app.go b/internal/tui/app.go index 6907c64f..674d2167 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" @@ -17,26 +18,35 @@ 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 - modelRefreshID 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 + 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 + modelRefreshID string + focus panel + width int + height int + styles styles } func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { @@ -52,6 +62,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) @@ -68,7 +82,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 "> " @@ -105,21 +119,24 @@ 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, + codeCopyBlocks: make(map[int]string), + nowFn: time.Now, + focus: panelInput, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index fa16e18a..94355d36 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..b92b87fa --- /dev/null +++ b/internal/tui/copy_code.go @@ -0,0 +1,308 @@ +package tui + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type copyCodeButtonBinding struct { + ID int + Code string +} + +type markdownSegmentKind int + +const ( + markdownSegmentText markdownSegmentKind = iota + markdownSegmentCode +) + +type markdownSegment struct { + Kind markdownSegmentKind + Text string + Fenced string + Code string +} + +var ( + copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) + clipboardWriteAll = clipboard.WriteAll +) + +func splitMarkdownSegments(content string) []markdownSegment { + if !strings.Contains(content, "```") { + return splitIndentedCodeSegments(content) + } + + 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 + + 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] + } + + 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) + } + + if inFence { + flushCode() + } + flushText() + + if sawFence && len(segments) > 0 { + return segments + } + + return splitIndentedCodeSegments(content) +} + +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)) + for _, segment := range segments { + if segment.Kind == markdownSegmentCode && strings.TrimSpace(segment.Code) != "" { + blocks = append(blocks, segment.Code) + } + } + return blocks +} + +func parseFenceOpenLine(line string) (string, bool) { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, "```") { + return "", false + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, "```")), true +} + +func isFenceCloseLine(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + return strings.TrimSpace(trimmed) == "```" +} + +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 { + a.codeCopyBlocks[binding.ID] = binding.Code + } +} + +func parseCopyCodeButton(line string) (id int, startCol int, endCol int, ok bool) { + clean := ansiEscapePattern.ReplaceAllString(line, "") + matches := copyCodeButtonPattern.FindStringSubmatchIndex(clean) + if len(matches) < 4 { + return 0, 0, 0, false + } + + buttonText := clean[matches[0]:matches[1]] + idText := clean[matches[2]:matches[3]] + id, err := strconv.Atoi(idText) + if err != nil { + return 0, 0, 0, false + } + + startCol = lipgloss.Width(clean[:matches[0]]) + endCol = startCol + lipgloss.Width(buttonText) + return id, startCol, endCol, true +} + +func (a *App) copyButtonIDAtMouse(msg tea.MouseMsg) (int, bool) { + line, relativeX, ok := a.transcriptLineAtMouse(msg) + if !ok { + return 0, false + } + + buttonID, startCol, endCol, ok := parseCopyCodeButton(line) + if !ok { + return 0, false + } + if relativeX < startCol || relativeX >= endCol { + return 0, false + } + return buttonID, true +} + +func (a *App) copyCodeBlockByID(buttonID int) bool { + 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) (line string, relativeX int, ok bool) { + if !a.isMouseWithinTranscript(msg) { + return "", 0, false + } + + x, y, _, _ := a.transcriptBounds() + lineIndex := msg.Y - y + if lineIndex < 0 { + return "", 0, false + } + + lines := strings.Split(a.transcript.View(), "\n") + if lineIndex >= len(lines) { + return "", 0, false + } + return lines[lineIndex], msg.X - x, true +} diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go new file mode 100644 index 00000000..4c77ca5d --- /dev/null +++ b/internal/tui/copy_code_test.go @@ -0,0 +1,271 @@ +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 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) + 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 { + 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 := parseCopyCodeButton("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) + } + 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) + } +} + +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() + 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() + 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 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)" { + 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) + } + + if handled := app.handleTranscriptMouse(tea.MouseMsg{ + X: x + 60, + Y: y + targetY, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + Type: tea.MouseRelease, + }); handled { + t.Fatalf("expected release 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) { + 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 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 == "" { + 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/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..1dbc4f72 --- /dev/null +++ b/internal/tui/markdown_renderer_test.go @@ -0,0 +1,99 @@ +package tui + +import ( + "regexp" + "strings" + "testing" +) + +var markdownTestANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +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)) + } +} + +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/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 5d8d82a9..dc4b6349 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -39,6 +39,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) { @@ -159,6 +163,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 @@ -178,8 +185,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() @@ -228,94 +241,200 @@ 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() + if cmd := a.requestModelCatalogRefresh(a.state.CurrentProvider); cmd != nil { + cmds = append(cmds, cmd) + } return a, tea.Batch(cmds...) } - a.openModelPicker() - if cmd := a.requestModelCatalogRefresh(a.state.CurrentProvider); cmd != nil { - cmds = append(cmds, cmd) - } - 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): @@ -603,17 +722,50 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { } func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { - if !a.isMouseWithinTranscript(msg) { - return false - } - - switch msg.Button { - case tea.MouseButtonWheelUp: + 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 tea.MouseButtonWheelDown: + 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 + } + return false + } + + switch { + case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: + return false + case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: + 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 } @@ -645,6 +797,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 @@ -751,6 +984,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 @@ -758,9 +992,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/update_test.go b/internal/tui/update_test.go index 6942e988..ff498a0a 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), @@ -682,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}, @@ -929,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 { @@ -1325,7 +1578,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) } } @@ -1433,6 +1686,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() @@ -1474,9 +1809,13 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { } rendered := app.renderMessageContent("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody) - if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { + rendered = stripANSI(rendered) + 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) @@ -1490,6 +1829,123 @@ 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 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 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() + 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, "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) + } +} + +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) + } +} + func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { previousExecutor := workspaceCommandExecutor t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) @@ -1660,3 +2116,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..5b14caf9 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" @@ -170,7 +171,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()) @@ -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) @@ -257,21 +263,22 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { content = emptyMessageText } - contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) + var ( + contentBlock string + copyButtons []copyCodeButtonBinding + ) if message.Role == roleUser { - contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) + contentBlock = bodyStyle.Render(wrapPlain(content, max(16, maxMessageWidth-2))) + } else { + contentBlock, copyButtons = a.renderMessageContentWithCopy(content, maxMessageWidth-2, bodyStyle, startCopyID) } - - block := lipgloss.JoinVertical( - blockAlign, - tagStyle.Render(tag), - contentBlock, - ) + parts := []string{tagStyle.Render(tag), contentBlock} + 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 { @@ -342,45 +349,101 @@ 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) } func (a App) renderMessageContent(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))) + 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), nil } - blocks := make([]string, 0, len(parts)) - for i, part := range parts { - if i%2 == 0 { - trimmed := strings.Trim(part, "\n") - if trimmed == "" { + 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 + } + rendered = trimRenderedTrailingWhitespace(rendered) + 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 } - blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) - continue + rendered, err := a.markdownRenderer.Render(segment.Text, max(16, width-2)) + if err != nil { + continue + } + rendered = trimRenderedTrailingWhitespace(rendered) + renderedParts = append(renderedParts, bodyStyle.Render(normalizeBlockRightEdge(rendered, max(1, width)))) + case markdownSegmentCode: + code := strings.TrimRight(segment.Code, "\n") + if code == "" { + continue + } + buttonText := fmt.Sprintf(copyCodeButton, nextCopyID) + button := a.styles.codeCopyButton.Render(buttonText) + 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++ } + } - 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))) + if len(renderedParts) == 0 { + return bodyStyle.Render(emptyMessageText), nil + } + return lipgloss.JoinVertical(lipgloss.Left, renderedParts...), copyBindings +} + +func normalizeBlockRightEdge(content string, maxWidth int) string { + if strings.TrimSpace(content) == "" { + return content } - if len(blocks) == 0 { - return bodyStyle.Render(emptyMessageText) + lines := strings.Split(content, "\n") + targetWidth := 0 + for _, line := range lines { + targetWidth = max(targetWidth, lipgloss.Width(line)) } + targetWidth = clamp(targetWidth, 1, maxWidth) - return lipgloss.JoinVertical(lipgloss.Left, blocks...) + 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 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 {