diff --git a/go.mod b/go.mod index c31b356..46f6550 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,42 @@ go 1.26 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/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 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.1 // 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.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.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.19 // 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/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/net v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index 6c5a4d9..8b8c886 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,37 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +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/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +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= @@ -26,34 +40,55 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/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/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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/tui/app.go b/internal/tui/app.go index cf80731..14580ad 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -10,6 +10,7 @@ import ( "agentu/internal/agent" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -70,6 +71,7 @@ type model struct { viewport viewport.Model input textarea.Model + spinner spinner.Model width int height int @@ -118,6 +120,10 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { applyTextareaTheme(&input, th) input.Focus() + spin := spinner.New( + spinner.WithSpinner(spinner.Line), + ) + vp := viewport.New(80, 20) vp.Style = st.Viewport vp.SetContent("") @@ -132,10 +138,8 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { styles: st, viewport: vp, input: input, + spinner: spin, status: "ready", - messages: []message{ - {role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."}, - }, } } @@ -154,6 +158,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport(true) return m, nil + case spinner.TickMsg: + if !m.running { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tea.KeyMsg: switch msg.String() { case "ctrl+c": @@ -201,6 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = "ready" } + m.resize() m.refreshViewport(true) cmds = append(cmds, textarea.Blink) } @@ -239,10 +252,12 @@ func (m model) View() string { return "agentu" } - header := m.headerView() - status := m.statusView() - input := m.inputView() - body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status) + parts := []string{m.headerView(), m.viewport.View()} + if activity := m.activityView(); activity != "" { + parts = append(parts, activity) + } + parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView()) + body := lipgloss.JoinVertical(lipgloss.Left, parts...) return m.styles.App.Width(m.width).Height(m.height).Render(body) } @@ -276,6 +291,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) { m.afterInputChanged() m.running = true m.status = "thinking..." + m.resize() m.refreshViewport(true) turnCtx, cancel := context.WithCancel(m.ctx) @@ -301,7 +317,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) { return turnStartedMsg{} } - return *m, tea.Batch(start, m.waitForEvent()) + return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick) } func (m *model) insertInputNewline() (tea.Model, tea.Cmd) { @@ -343,10 +359,14 @@ func (m *model) resize() { m.syncInputHeight() headerHeight := 1 - statusHeight := 1 + activityHeight := 0 + if m.running { + activityHeight = 1 + } inputHeight := m.inputBlockHeight() + footerHeight := 2 m.viewport.Width = width - m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight) + m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight) m.viewport.Style = m.styles.Viewport } @@ -369,80 +389,115 @@ func (m model) renderMessages() string { parts = append(parts, m.messageView(msg.role, content, contentWidth)) } if len(parts) == 0 { - return m.styles.Muted.Width(contentWidth).Render("Start a conversation.") + return "" } return strings.Join(parts, "\n\n") } func (m model) messageView(r role, content string, width int) string { - label := string(r) + label := roleLabel(r) style := m.styles.AssistantMsg labelStyle := m.styles.AssistantLabel switch r { case roleUser: - label = "you" style = m.styles.UserMessage labelStyle = m.styles.UserLabel case roleTool: - label = "tool" style = m.styles.ToolMessage labelStyle = m.styles.ToolLabel case roleError: - label = "error" style = m.styles.ErrorMessage labelStyle = m.styles.ErrorLabel case roleSystem: - label = "note" style = m.styles.SystemMessage labelStyle = m.styles.SystemLabel - default: - label = "agentu" + } + + bodyContent := content + if r == roleAssistant { + bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize()) + bodyContent = renderMarkdown(content, bodyWidth, m.theme) } labelLine := labelStyle.Render(label) - body := style.Width(width).Render(content) + body := style.Width(width).Render(bodyContent) return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) } +func roleLabel(r role) string { + switch r { + case roleUser: + return "You" + case roleAssistant: + return "Agentu" + case roleTool: + return "Tool" + case roleError: + return "Error" + case roleSystem: + return "Note" + default: + return string(r) + } +} + func (m model) headerView() string { + title := m.styles.Title.Render("agentu") + meta := m.styles.Muted.Render("local agent") + line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta) + return m.styles.Header.Width(max(1, m.width)).Render(line) +} + +func (m model) modelMetaView() string { mode := "read-only tools" if m.yolo { mode = "yolo tools" } modelName := m.modelName - thinking := "" + parts := []string{} if m.models != nil { + if provider := m.models.CurrentProvider(); provider != "" { + parts = append(parts, "provider "+provider) + } modelName = m.models.CurrentModel() if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { - thinking = " · thinking " + currentThinking + parts = append(parts, "thinking "+currentThinking) } } - title := m.styles.Title.Render("agentu") - meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode)) - line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta) - return m.styles.Header.Width(max(1, m.width)).Render(line) + parts = append([]string{modelName, mode}, parts...) + parts = append(parts, "theme "+string(m.theme.Mode)) + line := fitLine(strings.Join(parts, " · "), max(1, m.width-2)) + return m.styles.ModelMeta.Width(max(1, m.width)).Render(line) } func (m model) inputView() string { - title := m.styles.Muted.Render("Enter sends · ctrl+j newline · alt+enter newline · /clear · /exit") - if m.running { - title = m.styles.Muted.Render("Working · esc cancel · ctrl+c quit") - } suggestions := m.slashSuggestionsView() box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) if suggestions != "" { - return lipgloss.JoinVertical(lipgloss.Left, suggestions, title, box) + return lipgloss.JoinVertical(lipgloss.Left, suggestions, box) } - return lipgloss.JoinVertical(lipgloss.Left, title, box) + return box } -func (m model) statusView() string { - left := m.status - if left == "" { - left = "ready" +func (m model) activityView() string { + if !m.running { + return "" } - return m.styles.Status.Width(max(1, m.width)).Render(left) + status := strings.TrimSpace(m.status) + if status == "" || status == "ready" { + status = "working..." + } + line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2)) + return m.styles.Activity.Width(max(1, m.width)).Render(line) +} + +func (m model) footerView() string { + hint := "enter send · ctrl+j newline · alt+enter newline · / commands" + if m.running { + hint = "esc cancel · ctrl+c quit" + } + return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2))) } type eventWriter struct { @@ -514,7 +569,7 @@ func (m model) desiredInputHeight() int { } func (m model) inputBlockHeight() int { - height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() + height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() if m.showSlashSuggestions() { height++ } @@ -555,6 +610,29 @@ func (m model) modelInfo() string { return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode) } +func fitLine(text string, width int) string { + width = max(1, width) + if lipgloss.Width(text) <= width { + return text + } + if width <= 3 { + return strings.Repeat(".", width) + } + + target := width - 3 + used := 0 + var b strings.Builder + for _, r := range text { + next := lipgloss.Width(string(r)) + if used+next > target { + break + } + b.WriteRune(r) + used += next + } + return b.String() + "..." +} + func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { output, err := m.handleLocalCommand(input) role := roleSystem diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index ac76205..e043e04 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -20,6 +20,109 @@ func TestModelRendersChatShell(t *testing.T) { } } +func TestModelMetaRendersBelowInput(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + rendered := next.(model).View() + + inputIndex := strings.Index(rendered, "Message agentu") + modelIndex := strings.Index(rendered, "test-model") + if inputIndex < 0 || modelIndex < 0 { + t.Fatalf("rendered view missing input or model meta:\n%s", rendered) + } + if modelIndex < inputIndex { + t.Fatalf("model meta should render below input:\n%s", rendered) + } +} + +func TestInitialViewDoesNotRenderTopNote(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + rendered := next.(model).View() + + for _, unwanted := range []string{"Note", "Ask anything", "Start a conversation"} { + if strings.Contains(rendered, unwanted) { + t.Fatalf("initial view should not render %q:\n%s", unwanted, rendered) + } + } +} + +func TestRunningStateRendersActivityIndicator(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.running = true + m.status = "thinking..." + m.resize() + + rendered := m.View() + for _, want := range []string{"Working", "thinking...", "esc cancels"} { + if !strings.Contains(rendered, want) { + t.Fatalf("running view missing %q:\n%s", want, rendered) + } + } +} + +func TestUserMessageRendersOnLeft(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + rendered := m.messageView(roleUser, "hello", 80) + if strings.HasPrefix(rendered, " ") { + t.Fatalf("user message should not be right-aligned:\n%q", rendered) + } + if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") { + t.Fatalf("user message missing content:\n%s", rendered) + } +} + +func TestAssistantMessagesRenderMarkdown(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80) + for _, want := range []string{"Agentu", "Title", "item"} { + if !strings.Contains(rendered, want) { + t.Fatalf("assistant markdown missing %q:\n%s", want, rendered) + } + } + if strings.Contains(rendered, "# Title") { + t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered) + } +} + +func TestUserMessagesDoNotRenderMarkdown(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + rendered := m.messageView(roleUser, "# Title\n\n- item", 80) + for _, want := range []string{"# Title", "- item"} { + if !strings.Contains(rendered, want) { + t.Fatalf("user markdown should stay plain and include %q:\n%s", want, rendered) + } + } +} + +func TestMessageRoleLabels(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.messages = []message{ + {role: roleUser, content: "hello"}, + {role: roleAssistant, content: "hi"}, + } + + rendered := m.renderMessages() + for _, want := range []string{"You", "Agentu"} { + if !strings.Contains(rendered, want) { + t.Fatalf("messages missing %q:\n%s", want, rendered) + } + } +} + func TestCtrlJInsertsInputNewline(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.input.SetValue("hello") diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go new file mode 100644 index 0000000..f9dfcb1 --- /dev/null +++ b/internal/tui/markdown.go @@ -0,0 +1,68 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + glamstyles "github.com/charmbracelet/glamour/styles" +) + +const minMarkdownWidth = 20 + +func renderMarkdown(content string, width int, th theme) string { + width = max(minMarkdownWidth, width) + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(markdownStyle(th)), + glamour.WithWordWrap(width), + ) + if err != nil { + return content + } + + rendered, err := renderer.Render(content) + if err != nil { + return content + } + return strings.TrimRight(rendered, "\n") +} + +func markdownStyle(th theme) ansi.StyleConfig { + cfg := glamstyles.LightStyleConfig + if th.Mode == ThemeDark { + cfg = glamstyles.DarkStyleConfig + } + + zero := uint(0) + cfg.Document.Margin = &zero + cfg.Document.BlockPrefix = "" + cfg.Document.BlockSuffix = "" + cfg.Document.Color = stringPtr(th.Text) + + cfg.Heading.Color = stringPtr(th.Assistant) + cfg.H1.Prefix = "" + cfg.H1.Suffix = "" + cfg.H1.Color = stringPtr(th.Assistant) + cfg.H1.BackgroundColor = nil + cfg.H1.Bold = boolPtr(true) + + cfg.BlockQuote.Color = stringPtr(th.Muted) + cfg.Link.Color = stringPtr(th.User) + cfg.LinkText.Color = stringPtr(th.User) + + cfg.Code.Color = stringPtr(th.Tool) + cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt) + cfg.CodeBlock.Color = stringPtr(th.Text) + cfg.CodeBlock.BackgroundColor = nil + cfg.CodeBlock.Margin = &zero + + return cfg +} + +func stringPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/internal/tui/markdown_test.go b/internal/tui/markdown_test.go new file mode 100644 index 0000000..bbe180f --- /dev/null +++ b/internal/tui/markdown_test.go @@ -0,0 +1,44 @@ +package tui + +import ( + "regexp" + "strings" + "testing" +) + +func TestRenderMarkdownSupportsCommonAssistantContent(t *testing.T) { + rendered := renderMarkdown("# Title\n\n- one\n- two\n\n```go\nfmt.Println(\"x\")\n```", 80, themeForMode(ThemeLight)) + plain := stripANSI(rendered) + for _, want := range []string{"Title", "one", "two", `fmt.Println("x")`} { + if !strings.Contains(plain, want) { + t.Fatalf("markdown output missing %q:\n%s", want, rendered) + } + } + if strings.Contains(plain, "# Title") { + t.Fatalf("heading was not rendered:\n%s", rendered) + } +} + +func TestRenderMarkdownSupportsThemesAndNarrowWidth(t *testing.T) { + for _, mode := range []ThemeMode{ThemeLight, ThemeDark} { + rendered := renderMarkdown("**bold** and `code`", 4, themeForMode(mode)) + plain := stripANSI(rendered) + if rendered == "" { + t.Fatalf("empty markdown output for %s", mode) + } + for _, want := range []string{"bold", "code"} { + if !strings.Contains(plain, want) { + t.Fatalf("markdown output for %s missing %q:\n%s", mode, want, rendered) + } + } + if strings.Contains(plain, "**bold**") { + t.Fatalf("strong markdown was not rendered for %s:\n%s", mode, rendered) + } + } +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`) + +func stripANSI(value string) string { + return ansiPattern.ReplaceAllString(value, "") +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index cb32486..33f2dc5 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -49,7 +49,9 @@ type styles struct { Header lipgloss.Style Title lipgloss.Style Muted lipgloss.Style - Status lipgloss.Style + Activity lipgloss.Style + ModelMeta lipgloss.Style + Footer lipgloss.Style Viewport lipgloss.Style InputBox lipgloss.Style CommandHint lipgloss.Style @@ -126,11 +128,22 @@ func (t theme) styles() styles { Muted: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)), - Status: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Muted)). + Activity: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Background)). + Background(lipgloss.Color(t.Title)). + Padding(0, 1), + + ModelMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Text)). Background(lipgloss.Color(t.SurfaceAlt)). Padding(0, 1), + Footer: lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Muted)). + Background(lipgloss.Color(t.Background)). + Padding(0, 1), + Viewport: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Text)). Background(lipgloss.Color(t.Background)),